mirror of
https://github.com/bitwarden/server.git
synced 2025-07-13 13:47:30 -05: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:
61
src/Api/Billing/Attributes/InjectOrganizationAttribute.cs
Normal file
61
src/Api/Billing/Attributes/InjectOrganizationAttribute.cs
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
#nullable enable
|
||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.Models.Api;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
|
|
||||||
|
namespace Bit.Api.Billing.Attributes;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An action filter that facilitates the injection of a <see cref="Organization"/> parameter into the executing action method arguments.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>This attribute retrieves the organization associated with the 'organizationId' included in the executing context's route data. If the organization cannot be found,
|
||||||
|
/// the request is terminated with a not found response.</para>
|
||||||
|
/// <para>The injected <see cref="Organization"/>
|
||||||
|
/// parameter must be marked with a [BindNever] attribute to short-circuit the model-binding system.</para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <example>
|
||||||
|
/// <code><![CDATA[
|
||||||
|
/// [HttpPost]
|
||||||
|
/// [InjectOrganization]
|
||||||
|
/// public async Task<IResult> EndpointAsync([BindNever] Organization organization)
|
||||||
|
/// ]]></code>
|
||||||
|
/// </example>
|
||||||
|
/// <seealso cref="Microsoft.AspNetCore.Mvc.Filters.ActionFilterAttribute"/>
|
||||||
|
public class InjectOrganizationAttribute : ActionFilterAttribute
|
||||||
|
{
|
||||||
|
public override async Task OnActionExecutionAsync(
|
||||||
|
ActionExecutingContext context,
|
||||||
|
ActionExecutionDelegate next)
|
||||||
|
{
|
||||||
|
if (!context.RouteData.Values.TryGetValue("organizationId", out var routeValue) ||
|
||||||
|
!Guid.TryParse(routeValue?.ToString(), out var organizationId))
|
||||||
|
{
|
||||||
|
context.Result = new BadRequestObjectResult(new ErrorResponseModel("Route parameter 'organizationId' is missing or invalid."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var organizationRepository = context.HttpContext.RequestServices
|
||||||
|
.GetRequiredService<IOrganizationRepository>();
|
||||||
|
|
||||||
|
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||||
|
|
||||||
|
if (organization == null)
|
||||||
|
{
|
||||||
|
context.Result = new NotFoundObjectResult(new ErrorResponseModel("Organization not found."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var organizationParameter = context.ActionDescriptor.Parameters
|
||||||
|
.FirstOrDefault(p => p.ParameterType == typeof(Organization));
|
||||||
|
|
||||||
|
if (organizationParameter != null)
|
||||||
|
{
|
||||||
|
context.ActionArguments[organizationParameter.Name] = organization;
|
||||||
|
}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
}
|
||||||
|
}
|
80
src/Api/Billing/Attributes/InjectProviderAttribute.cs
Normal file
80
src/Api/Billing/Attributes/InjectProviderAttribute.cs
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
#nullable enable
|
||||||
|
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.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
|
|
||||||
|
namespace Bit.Api.Billing.Attributes;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An action filter that facilitates the injection of a <see cref="Provider"/> parameter into the executing action method arguments after performing an authorization check.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>This attribute retrieves the provider associated with the 'providerId' included in the executing context's route data. If the provider cannot be found,
|
||||||
|
/// the request is terminated with a not-found response. It then checks the authorization level for the provider using the provided <paramref name="providerUserType"/>.
|
||||||
|
/// If this check fails, the request is terminated with an unauthorized response.</para>
|
||||||
|
/// <para>The injected <see cref="Provider"/>
|
||||||
|
/// parameter must be marked with a [BindNever] attribute to short-circuit the model-binding system.</para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <example>
|
||||||
|
/// <code><![CDATA[
|
||||||
|
/// [HttpPost]
|
||||||
|
/// [InjectProvider(ProviderUserType.ProviderAdmin)]
|
||||||
|
/// public async Task<IResult> EndpointAsync([BindNever] Provider provider)
|
||||||
|
/// ]]></code>
|
||||||
|
/// </example>
|
||||||
|
/// <param name="providerUserType">The desired access level for the authorization check.</param>
|
||||||
|
/// <seealso cref="Microsoft.AspNetCore.Mvc.Filters.ActionFilterAttribute"/>
|
||||||
|
public class InjectProviderAttribute(ProviderUserType providerUserType) : ActionFilterAttribute
|
||||||
|
{
|
||||||
|
public override async Task OnActionExecutionAsync(
|
||||||
|
ActionExecutingContext context,
|
||||||
|
ActionExecutionDelegate next)
|
||||||
|
{
|
||||||
|
if (!context.RouteData.Values.TryGetValue("providerId", out var routeValue) ||
|
||||||
|
!Guid.TryParse(routeValue?.ToString(), out var providerId))
|
||||||
|
{
|
||||||
|
context.Result = new BadRequestObjectResult(new ErrorResponseModel("Route parameter 'providerId' is missing or invalid."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var providerRepository = context.HttpContext.RequestServices
|
||||||
|
.GetRequiredService<IProviderRepository>();
|
||||||
|
|
||||||
|
var provider = await providerRepository.GetByIdAsync(providerId);
|
||||||
|
|
||||||
|
if (provider == null)
|
||||||
|
{
|
||||||
|
context.Result = new NotFoundObjectResult(new ErrorResponseModel("Provider not found."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentContext = context.HttpContext.RequestServices.GetRequiredService<ICurrentContext>();
|
||||||
|
|
||||||
|
var unauthorized = providerUserType switch
|
||||||
|
{
|
||||||
|
ProviderUserType.ProviderAdmin => !currentContext.ProviderProviderAdmin(providerId),
|
||||||
|
ProviderUserType.ServiceUser => !currentContext.ProviderUser(providerId),
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
|
||||||
|
if (unauthorized)
|
||||||
|
{
|
||||||
|
context.Result = new UnauthorizedObjectResult(new ErrorResponseModel("Unauthorized."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var providerParameter = context.ActionDescriptor.Parameters
|
||||||
|
.FirstOrDefault(p => p.ParameterType == typeof(Provider));
|
||||||
|
|
||||||
|
if (providerParameter != null)
|
||||||
|
{
|
||||||
|
context.ActionArguments[providerParameter.Name] = provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
}
|
||||||
|
}
|
53
src/Api/Billing/Attributes/InjectUserAttribute.cs
Normal file
53
src/Api/Billing/Attributes/InjectUserAttribute.cs
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
#nullable enable
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Models.Api;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
|
|
||||||
|
namespace Bit.Api.Billing.Attributes;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An action filter that facilitates the injection of a <see cref="User"/> parameter into the executing action method arguments.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>This attribute retrieves the authorized user associated with the current HTTP context using the <see cref="IUserService"/> service.
|
||||||
|
/// If the user is unauthorized or cannot be found, the request is terminated with an unauthorized response.</para>
|
||||||
|
/// <para>The injected <see cref="User"/>
|
||||||
|
/// parameter must be marked with a [BindNever] attribute to short-circuit the model-binding system.</para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <example>
|
||||||
|
/// <code><![CDATA[
|
||||||
|
/// [HttpPost]
|
||||||
|
/// [InjectUser]
|
||||||
|
/// public async Task<IResult> EndpointAsync([BindNever] User user)
|
||||||
|
/// ]]></code>
|
||||||
|
/// </example>
|
||||||
|
/// <seealso cref="ActionFilterAttribute"/>
|
||||||
|
public class InjectUserAttribute : ActionFilterAttribute
|
||||||
|
{
|
||||||
|
public override async Task OnActionExecutionAsync(
|
||||||
|
ActionExecutingContext context,
|
||||||
|
ActionExecutionDelegate next)
|
||||||
|
{
|
||||||
|
var userService = context.HttpContext.RequestServices.GetRequiredService<IUserService>();
|
||||||
|
|
||||||
|
var user = await userService.GetUserByPrincipalAsync(context.HttpContext.User);
|
||||||
|
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
context.Result = new UnauthorizedObjectResult(new ErrorResponseModel("Unauthorized."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var userParameter =
|
||||||
|
context.ActionDescriptor.Parameters.FirstOrDefault(parameter => parameter.ParameterType == typeof(User));
|
||||||
|
|
||||||
|
if (userParameter != null)
|
||||||
|
{
|
||||||
|
context.ActionArguments[userParameter.Name] = user;
|
||||||
|
}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,6 @@
|
|||||||
using Bit.Core.Models.Api;
|
#nullable enable
|
||||||
|
using Bit.Core.Billing.Commands;
|
||||||
|
using Bit.Core.Models.Api;
|
||||||
using Microsoft.AspNetCore.Http.HttpResults;
|
using Microsoft.AspNetCore.Http.HttpResults;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
@ -6,20 +8,50 @@ namespace Bit.Api.Billing.Controllers;
|
|||||||
|
|
||||||
public abstract class BaseBillingController : Controller
|
public abstract class BaseBillingController : Controller
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Processes the result of a billing command and converts it to an appropriate HTTP result response.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Result to response mappings:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description><typeparamref name="T"/>: 200 OK</description></item>
|
||||||
|
/// <item><description><see cref="Core.Billing.Commands.BadRequest"/>: 400 BAD_REQUEST</description></item>
|
||||||
|
/// <item><description><see cref="Core.Billing.Commands.Conflict"/>: 409 CONFLICT</description></item>
|
||||||
|
/// <item><description><see cref="Unhandled"/>: 500 INTERNAL_SERVER_ERROR</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </remarks>
|
||||||
|
/// <typeparam name="T">The type of the successful result.</typeparam>
|
||||||
|
/// <param name="result">The result of executing the billing command.</param>
|
||||||
|
/// <returns>An HTTP result response representing the outcome of the command execution.</returns>
|
||||||
|
protected static IResult Handle<T>(BillingCommandResult<T> result) =>
|
||||||
|
result.Match<IResult>(
|
||||||
|
TypedResults.Ok,
|
||||||
|
badRequest => Error.BadRequest(badRequest.Response),
|
||||||
|
conflict => Error.Conflict(conflict.Response),
|
||||||
|
unhandled => Error.ServerError(unhandled.Response, unhandled.Exception));
|
||||||
|
|
||||||
protected static class Error
|
protected static class Error
|
||||||
{
|
{
|
||||||
public static BadRequest<ErrorResponseModel> BadRequest(Dictionary<string, IEnumerable<string>> errors) =>
|
|
||||||
TypedResults.BadRequest(new ErrorResponseModel(errors));
|
|
||||||
|
|
||||||
public static BadRequest<ErrorResponseModel> BadRequest(string message) =>
|
public static BadRequest<ErrorResponseModel> BadRequest(string message) =>
|
||||||
TypedResults.BadRequest(new ErrorResponseModel(message));
|
TypedResults.BadRequest(new ErrorResponseModel(message));
|
||||||
|
|
||||||
|
public static JsonHttpResult<ErrorResponseModel> Conflict(string message) =>
|
||||||
|
TypedResults.Json(
|
||||||
|
new ErrorResponseModel(message),
|
||||||
|
statusCode: StatusCodes.Status409Conflict);
|
||||||
|
|
||||||
public static NotFound<ErrorResponseModel> NotFound() =>
|
public static NotFound<ErrorResponseModel> NotFound() =>
|
||||||
TypedResults.NotFound(new ErrorResponseModel("Resource not found."));
|
TypedResults.NotFound(new ErrorResponseModel("Resource not found."));
|
||||||
|
|
||||||
public static JsonHttpResult<ErrorResponseModel> ServerError(string message = "Something went wrong with your request. Please contact support.") =>
|
public static JsonHttpResult<ErrorResponseModel> ServerError(
|
||||||
|
string message = "Something went wrong with your request. Please contact support for assistance.",
|
||||||
|
Exception? exception = null) =>
|
||||||
TypedResults.Json(
|
TypedResults.Json(
|
||||||
new ErrorResponseModel(message),
|
exception == null ? new ErrorResponseModel(message) : new ErrorResponseModel(message)
|
||||||
|
{
|
||||||
|
ExceptionMessage = exception.Message,
|
||||||
|
ExceptionStackTrace = exception.StackTrace
|
||||||
|
},
|
||||||
statusCode: StatusCodes.Status500InternalServerError);
|
statusCode: StatusCodes.Status500InternalServerError);
|
||||||
|
|
||||||
public static JsonHttpResult<ErrorResponseModel> Unauthorized(string message = "Unauthorized.") =>
|
public static JsonHttpResult<ErrorResponseModel> Unauthorized(string message = "Unauthorized.") =>
|
||||||
|
@ -28,9 +28,6 @@ public class TaxController(
|
|||||||
|
|
||||||
var result = await previewTaxAmountCommand.Run(parameters);
|
var result = await previewTaxAmountCommand.Run(parameters);
|
||||||
|
|
||||||
return result.Match<IResult>(
|
return Handle(result);
|
||||||
taxAmount => TypedResults.Ok(new { TaxAmount = taxAmount }),
|
|
||||||
badRequest => Error.BadRequest(badRequest.TranslationKey),
|
|
||||||
unhandled => Error.ServerError(unhandled.TranslationKey));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,64 @@
|
|||||||
|
#nullable enable
|
||||||
|
using Bit.Api.Billing.Attributes;
|
||||||
|
using Bit.Api.Billing.Models.Requests.Payment;
|
||||||
|
using Bit.Core.Billing.Payment.Commands;
|
||||||
|
using Bit.Core.Billing.Payment.Queries;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Utilities;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||||
|
|
||||||
|
namespace Bit.Api.Billing.Controllers.VNext;
|
||||||
|
|
||||||
|
[Authorize("Application")]
|
||||||
|
[Route("account/billing/vnext")]
|
||||||
|
[SelfHosted(NotSelfHostedOnly = true)]
|
||||||
|
public class AccountBillingVNextController(
|
||||||
|
ICreateBitPayInvoiceForCreditCommand createBitPayInvoiceForCreditCommand,
|
||||||
|
IGetCreditQuery getCreditQuery,
|
||||||
|
IGetPaymentMethodQuery getPaymentMethodQuery,
|
||||||
|
IUpdatePaymentMethodCommand updatePaymentMethodCommand) : BaseBillingController
|
||||||
|
{
|
||||||
|
[HttpGet("credit")]
|
||||||
|
[InjectUser]
|
||||||
|
public async Task<IResult> GetCreditAsync(
|
||||||
|
[BindNever] User user)
|
||||||
|
{
|
||||||
|
var credit = await getCreditQuery.Run(user);
|
||||||
|
return TypedResults.Ok(credit);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("credit/bitpay")]
|
||||||
|
[InjectUser]
|
||||||
|
public async Task<IResult> AddCreditViaBitPayAsync(
|
||||||
|
[BindNever] User user,
|
||||||
|
[FromBody] BitPayCreditRequest request)
|
||||||
|
{
|
||||||
|
var result = await createBitPayInvoiceForCreditCommand.Run(
|
||||||
|
user,
|
||||||
|
request.Amount,
|
||||||
|
request.RedirectUrl);
|
||||||
|
return Handle(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("payment-method")]
|
||||||
|
[InjectUser]
|
||||||
|
public async Task<IResult> GetPaymentMethodAsync(
|
||||||
|
[BindNever] User user)
|
||||||
|
{
|
||||||
|
var paymentMethod = await getPaymentMethodQuery.Run(user);
|
||||||
|
return TypedResults.Ok(paymentMethod);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("payment-method")]
|
||||||
|
[InjectUser]
|
||||||
|
public async Task<IResult> UpdatePaymentMethodAsync(
|
||||||
|
[BindNever] User user,
|
||||||
|
[FromBody] TokenizedPaymentMethodRequest request)
|
||||||
|
{
|
||||||
|
var (paymentMethod, billingAddress) = request.ToDomain();
|
||||||
|
var result = await updatePaymentMethodCommand.Run(user, paymentMethod, billingAddress);
|
||||||
|
return Handle(result);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,107 @@
|
|||||||
|
#nullable enable
|
||||||
|
using Bit.Api.AdminConsole.Authorization;
|
||||||
|
using Bit.Api.Billing.Attributes;
|
||||||
|
using Bit.Api.Billing.Models.Requests.Payment;
|
||||||
|
using Bit.Api.Billing.Models.Requirements;
|
||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.Billing.Payment.Commands;
|
||||||
|
using Bit.Core.Billing.Payment.Queries;
|
||||||
|
using Bit.Core.Utilities;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||||
|
// ReSharper disable RouteTemplates.MethodMissingRouteParameters
|
||||||
|
|
||||||
|
namespace Bit.Api.Billing.Controllers.VNext;
|
||||||
|
|
||||||
|
[Authorize("Application")]
|
||||||
|
[Route("organizations/{organizationId:guid}/billing/vnext")]
|
||||||
|
[SelfHosted(NotSelfHostedOnly = true)]
|
||||||
|
public class OrganizationBillingVNextController(
|
||||||
|
ICreateBitPayInvoiceForCreditCommand createBitPayInvoiceForCreditCommand,
|
||||||
|
IGetBillingAddressQuery getBillingAddressQuery,
|
||||||
|
IGetCreditQuery getCreditQuery,
|
||||||
|
IGetPaymentMethodQuery getPaymentMethodQuery,
|
||||||
|
IUpdateBillingAddressCommand updateBillingAddressCommand,
|
||||||
|
IUpdatePaymentMethodCommand updatePaymentMethodCommand,
|
||||||
|
IVerifyBankAccountCommand verifyBankAccountCommand) : BaseBillingController
|
||||||
|
{
|
||||||
|
[Authorize<ManageOrganizationBillingRequirement>]
|
||||||
|
[HttpGet("address")]
|
||||||
|
[InjectOrganization]
|
||||||
|
public async Task<IResult> GetBillingAddressAsync(
|
||||||
|
[BindNever] Organization organization)
|
||||||
|
{
|
||||||
|
var billingAddress = await getBillingAddressQuery.Run(organization);
|
||||||
|
return TypedResults.Ok(billingAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize<ManageOrganizationBillingRequirement>]
|
||||||
|
[HttpPut("address")]
|
||||||
|
[InjectOrganization]
|
||||||
|
public async Task<IResult> UpdateBillingAddressAsync(
|
||||||
|
[BindNever] Organization organization,
|
||||||
|
[FromBody] BillingAddressRequest request)
|
||||||
|
{
|
||||||
|
var billingAddress = request.ToDomain();
|
||||||
|
var result = await updateBillingAddressCommand.Run(organization, billingAddress);
|
||||||
|
return Handle(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize<ManageOrganizationBillingRequirement>]
|
||||||
|
[HttpGet("credit")]
|
||||||
|
[InjectOrganization]
|
||||||
|
public async Task<IResult> GetCreditAsync(
|
||||||
|
[BindNever] Organization organization)
|
||||||
|
{
|
||||||
|
var credit = await getCreditQuery.Run(organization);
|
||||||
|
return TypedResults.Ok(credit);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize<ManageOrganizationBillingRequirement>]
|
||||||
|
[HttpPost("credit/bitpay")]
|
||||||
|
[InjectOrganization]
|
||||||
|
public async Task<IResult> AddCreditViaBitPayAsync(
|
||||||
|
[BindNever] Organization organization,
|
||||||
|
[FromBody] BitPayCreditRequest request)
|
||||||
|
{
|
||||||
|
var result = await createBitPayInvoiceForCreditCommand.Run(
|
||||||
|
organization,
|
||||||
|
request.Amount,
|
||||||
|
request.RedirectUrl);
|
||||||
|
return Handle(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize<ManageOrganizationBillingRequirement>]
|
||||||
|
[HttpGet("payment-method")]
|
||||||
|
[InjectOrganization]
|
||||||
|
public async Task<IResult> GetPaymentMethodAsync(
|
||||||
|
[BindNever] Organization organization)
|
||||||
|
{
|
||||||
|
var paymentMethod = await getPaymentMethodQuery.Run(organization);
|
||||||
|
return TypedResults.Ok(paymentMethod);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize<ManageOrganizationBillingRequirement>]
|
||||||
|
[HttpPut("payment-method")]
|
||||||
|
[InjectOrganization]
|
||||||
|
public async Task<IResult> UpdatePaymentMethodAsync(
|
||||||
|
[BindNever] Organization organization,
|
||||||
|
[FromBody] TokenizedPaymentMethodRequest request)
|
||||||
|
{
|
||||||
|
var (paymentMethod, billingAddress) = request.ToDomain();
|
||||||
|
var result = await updatePaymentMethodCommand.Run(organization, paymentMethod, billingAddress);
|
||||||
|
return Handle(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize<ManageOrganizationBillingRequirement>]
|
||||||
|
[HttpPost("payment-method/verify-bank-account")]
|
||||||
|
[InjectOrganization]
|
||||||
|
public async Task<IResult> VerifyBankAccountAsync(
|
||||||
|
[BindNever] Organization organization,
|
||||||
|
[FromBody] VerifyBankAccountRequest request)
|
||||||
|
{
|
||||||
|
var result = await verifyBankAccountCommand.Run(organization, request.DescriptorCode);
|
||||||
|
return Handle(result);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,97 @@
|
|||||||
|
#nullable enable
|
||||||
|
using Bit.Api.Billing.Attributes;
|
||||||
|
using Bit.Api.Billing.Models.Requests.Payment;
|
||||||
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
|
using Bit.Core.Billing.Payment.Commands;
|
||||||
|
using Bit.Core.Billing.Payment.Queries;
|
||||||
|
using Bit.Core.Utilities;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||||
|
// ReSharper disable RouteTemplates.MethodMissingRouteParameters
|
||||||
|
|
||||||
|
namespace Bit.Api.Billing.Controllers.VNext;
|
||||||
|
|
||||||
|
[Route("providers/{providerId:guid}/billing/vnext")]
|
||||||
|
[SelfHosted(NotSelfHostedOnly = true)]
|
||||||
|
public class ProviderBillingVNextController(
|
||||||
|
ICreateBitPayInvoiceForCreditCommand createBitPayInvoiceForCreditCommand,
|
||||||
|
IGetBillingAddressQuery getBillingAddressQuery,
|
||||||
|
IGetCreditQuery getCreditQuery,
|
||||||
|
IGetPaymentMethodQuery getPaymentMethodQuery,
|
||||||
|
IUpdateBillingAddressCommand updateBillingAddressCommand,
|
||||||
|
IUpdatePaymentMethodCommand updatePaymentMethodCommand,
|
||||||
|
IVerifyBankAccountCommand verifyBankAccountCommand) : BaseBillingController
|
||||||
|
{
|
||||||
|
[HttpGet("address")]
|
||||||
|
[InjectProvider(ProviderUserType.ProviderAdmin)]
|
||||||
|
public async Task<IResult> GetBillingAddressAsync(
|
||||||
|
[BindNever] Provider provider)
|
||||||
|
{
|
||||||
|
var billingAddress = await getBillingAddressQuery.Run(provider);
|
||||||
|
return TypedResults.Ok(billingAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("address")]
|
||||||
|
[InjectProvider(ProviderUserType.ProviderAdmin)]
|
||||||
|
public async Task<IResult> UpdateBillingAddressAsync(
|
||||||
|
[BindNever] Provider provider,
|
||||||
|
[FromBody] BillingAddressRequest request)
|
||||||
|
{
|
||||||
|
var billingAddress = request.ToDomain();
|
||||||
|
var result = await updateBillingAddressCommand.Run(provider, billingAddress);
|
||||||
|
return Handle(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("credit")]
|
||||||
|
[InjectProvider(ProviderUserType.ProviderAdmin)]
|
||||||
|
public async Task<IResult> GetCreditAsync(
|
||||||
|
[BindNever] Provider provider)
|
||||||
|
{
|
||||||
|
var credit = await getCreditQuery.Run(provider);
|
||||||
|
return TypedResults.Ok(credit);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("credit/bitpay")]
|
||||||
|
[InjectProvider(ProviderUserType.ProviderAdmin)]
|
||||||
|
public async Task<IResult> AddCreditViaBitPayAsync(
|
||||||
|
[BindNever] Provider provider,
|
||||||
|
[FromBody] BitPayCreditRequest request)
|
||||||
|
{
|
||||||
|
var result = await createBitPayInvoiceForCreditCommand.Run(
|
||||||
|
provider,
|
||||||
|
request.Amount,
|
||||||
|
request.RedirectUrl);
|
||||||
|
return Handle(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("payment-method")]
|
||||||
|
[InjectProvider(ProviderUserType.ProviderAdmin)]
|
||||||
|
public async Task<IResult> GetPaymentMethodAsync(
|
||||||
|
[BindNever] Provider provider)
|
||||||
|
{
|
||||||
|
var paymentMethod = await getPaymentMethodQuery.Run(provider);
|
||||||
|
return TypedResults.Ok(paymentMethod);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("payment-method")]
|
||||||
|
[InjectProvider(ProviderUserType.ProviderAdmin)]
|
||||||
|
public async Task<IResult> UpdatePaymentMethodAsync(
|
||||||
|
[BindNever] Provider provider,
|
||||||
|
[FromBody] TokenizedPaymentMethodRequest request)
|
||||||
|
{
|
||||||
|
var (paymentMethod, billingAddress) = request.ToDomain();
|
||||||
|
var result = await updatePaymentMethodCommand.Run(provider, paymentMethod, billingAddress);
|
||||||
|
return Handle(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("payment-method/verify-bank-account")]
|
||||||
|
[InjectProvider(ProviderUserType.ProviderAdmin)]
|
||||||
|
public async Task<IResult> VerifyBankAccountAsync(
|
||||||
|
[BindNever] Provider provider,
|
||||||
|
[FromBody] VerifyBankAccountRequest request)
|
||||||
|
{
|
||||||
|
var result = await verifyBankAccountCommand.Run(provider, request.DescriptorCode);
|
||||||
|
return Handle(result);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,20 @@
|
|||||||
|
#nullable enable
|
||||||
|
using Bit.Core.Billing.Payment.Models;
|
||||||
|
|
||||||
|
namespace Bit.Api.Billing.Models.Requests.Payment;
|
||||||
|
|
||||||
|
public record BillingAddressRequest : CheckoutBillingAddressRequest
|
||||||
|
{
|
||||||
|
public string? Line1 { get; set; }
|
||||||
|
public string? Line2 { get; set; }
|
||||||
|
public string? City { get; set; }
|
||||||
|
public string? State { get; set; }
|
||||||
|
|
||||||
|
public override BillingAddress ToDomain() => base.ToDomain() with
|
||||||
|
{
|
||||||
|
Line1 = Line1,
|
||||||
|
Line2 = Line2,
|
||||||
|
City = City,
|
||||||
|
State = State,
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,13 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Bit.Api.Billing.Models.Requests.Payment;
|
||||||
|
|
||||||
|
public record BitPayCreditRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public required decimal Amount { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public required string RedirectUrl { get; set; } = null!;
|
||||||
|
}
|
@ -0,0 +1,24 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Bit.Core.Billing.Payment.Models;
|
||||||
|
|
||||||
|
namespace Bit.Api.Billing.Models.Requests.Payment;
|
||||||
|
|
||||||
|
public record CheckoutBillingAddressRequest : MinimalBillingAddressRequest
|
||||||
|
{
|
||||||
|
public TaxIdRequest? TaxId { get; set; }
|
||||||
|
|
||||||
|
public override BillingAddress ToDomain() => base.ToDomain() with
|
||||||
|
{
|
||||||
|
TaxId = TaxId != null ? new TaxID(TaxId.Code, TaxId.Value) : null
|
||||||
|
};
|
||||||
|
|
||||||
|
public class TaxIdRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public string Code { get; set; } = null!;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string Value { get; set; } = null!;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,16 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Bit.Core.Billing.Payment.Models;
|
||||||
|
|
||||||
|
namespace Bit.Api.Billing.Models.Requests.Payment;
|
||||||
|
|
||||||
|
public record MinimalBillingAddressRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
[StringLength(2, MinimumLength = 2, ErrorMessage = "Country code must be 2 characters long.")]
|
||||||
|
public required string Country { get; set; } = null!;
|
||||||
|
[Required]
|
||||||
|
public required string PostalCode { get; set; } = null!;
|
||||||
|
|
||||||
|
public virtual BillingAddress ToDomain() => new() { Country = Country, PostalCode = PostalCode, };
|
||||||
|
}
|
@ -0,0 +1,39 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Bit.Api.Utilities;
|
||||||
|
using Bit.Core.Billing.Payment.Models;
|
||||||
|
|
||||||
|
namespace Bit.Api.Billing.Models.Requests.Payment;
|
||||||
|
|
||||||
|
public class TokenizedPaymentMethodRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
[StringMatches("bankAccount", "card", "payPal",
|
||||||
|
ErrorMessage = "Payment method type must be one of: bankAccount, card, payPal")]
|
||||||
|
public required string Type { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public required string Token { get; set; }
|
||||||
|
|
||||||
|
public MinimalBillingAddressRequest? BillingAddress { get; set; }
|
||||||
|
|
||||||
|
public (TokenizedPaymentMethod, BillingAddress?) ToDomain()
|
||||||
|
{
|
||||||
|
var paymentMethod = new TokenizedPaymentMethod
|
||||||
|
{
|
||||||
|
Type = Type switch
|
||||||
|
{
|
||||||
|
"bankAccount" => TokenizablePaymentMethodType.BankAccount,
|
||||||
|
"card" => TokenizablePaymentMethodType.Card,
|
||||||
|
"payPal" => TokenizablePaymentMethodType.PayPal,
|
||||||
|
_ => throw new InvalidOperationException(
|
||||||
|
$"Invalid value for {nameof(TokenizedPaymentMethod)}.{nameof(TokenizedPaymentMethod.Type)}")
|
||||||
|
},
|
||||||
|
Token = Token
|
||||||
|
};
|
||||||
|
|
||||||
|
var billingAddress = BillingAddress?.ToDomain();
|
||||||
|
|
||||||
|
return (paymentMethod, billingAddress);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Bit.Api.Billing.Models.Requests.Payment;
|
||||||
|
|
||||||
|
public class VerifyBankAccountRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public required string DescriptorCode { get; set; }
|
||||||
|
}
|
@ -0,0 +1,18 @@
|
|||||||
|
#nullable enable
|
||||||
|
using Bit.Api.AdminConsole.Authorization;
|
||||||
|
using Bit.Core.Context;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
|
||||||
|
namespace Bit.Api.Billing.Models.Requirements;
|
||||||
|
|
||||||
|
public class ManageOrganizationBillingRequirement : IOrganizationRequirement
|
||||||
|
{
|
||||||
|
public async Task<bool> AuthorizeAsync(
|
||||||
|
CurrentContextOrganization? organizationClaims,
|
||||||
|
Func<Task<bool>> isProviderUserForOrg)
|
||||||
|
=> organizationClaims switch
|
||||||
|
{
|
||||||
|
{ Type: OrganizationUserType.Owner } => true,
|
||||||
|
_ => await isProviderUserForOrg()
|
||||||
|
};
|
||||||
|
}
|
18
src/Api/Utilities/StringMatchesAttribute.cs
Normal file
18
src/Api/Utilities/StringMatchesAttribute.cs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Bit.Api.Utilities;
|
||||||
|
|
||||||
|
public class StringMatchesAttribute(params string[]? accepted) : ValidationAttribute
|
||||||
|
{
|
||||||
|
public override bool IsValid(object? value)
|
||||||
|
{
|
||||||
|
if (value is not string str ||
|
||||||
|
accepted == null ||
|
||||||
|
accepted.Length == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return accepted.Contains(str);
|
||||||
|
}
|
||||||
|
}
|
62
src/Core/Billing/Commands/BillingCommand.cs
Normal file
62
src/Core/Billing/Commands/BillingCommand.cs
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
using Bit.Core.Billing.Constants;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Stripe;
|
||||||
|
|
||||||
|
namespace Bit.Core.Billing.Commands;
|
||||||
|
|
||||||
|
using static StripeConstants;
|
||||||
|
|
||||||
|
public abstract class BillingCommand<T>(
|
||||||
|
ILogger<T> logger)
|
||||||
|
{
|
||||||
|
protected string CommandName => GetType().Name;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Executes the provided function within a predefined execution context, handling any exceptions that occur during the process.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TSuccess">The type of the successful result expected from the provided function.</typeparam>
|
||||||
|
/// <param name="function">A function that performs an operation and returns a <see cref="BillingCommandResult{TSuccess}"/>.</param>
|
||||||
|
/// <returns>A task that represents the operation. The result provides a <see cref="BillingCommandResult{TSuccess}"/> which may indicate success or an error outcome.</returns>
|
||||||
|
protected async Task<BillingCommandResult<TSuccess>> HandleAsync<TSuccess>(
|
||||||
|
Func<Task<BillingCommandResult<TSuccess>>> function)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await function();
|
||||||
|
}
|
||||||
|
catch (StripeException stripeException) when (ErrorCodes.Get().Contains(stripeException.StripeError.Code))
|
||||||
|
{
|
||||||
|
return stripeException.StripeError.Code switch
|
||||||
|
{
|
||||||
|
ErrorCodes.CustomerTaxLocationInvalid =>
|
||||||
|
new BadRequest("Your location wasn't recognized. Please ensure your country and postal code are valid and try again."),
|
||||||
|
|
||||||
|
ErrorCodes.PaymentMethodMicroDepositVerificationAttemptsExceeded =>
|
||||||
|
new BadRequest("You have exceeded the number of allowed verification attempts. Please contact support for assistance."),
|
||||||
|
|
||||||
|
ErrorCodes.PaymentMethodMicroDepositVerificationDescriptorCodeMismatch =>
|
||||||
|
new BadRequest("The verification code you provided does not match the one sent to your bank account. Please try again."),
|
||||||
|
|
||||||
|
ErrorCodes.PaymentMethodMicroDepositVerificationTimeout =>
|
||||||
|
new BadRequest("Your bank account was not verified within the required time period. Please contact support for assistance."),
|
||||||
|
|
||||||
|
ErrorCodes.TaxIdInvalid =>
|
||||||
|
new BadRequest("The tax ID number you provided was invalid. Please try again or contact support for assistance."),
|
||||||
|
|
||||||
|
_ => new Unhandled(stripeException)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (StripeException stripeException)
|
||||||
|
{
|
||||||
|
logger.LogError(stripeException,
|
||||||
|
"{Command}: An error occurred while communicating with Stripe | Code = {Code}", CommandName,
|
||||||
|
stripeException.StripeError.Code);
|
||||||
|
return new Unhandled(stripeException);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogError(exception, "{Command}: An unknown error occurred during execution", CommandName);
|
||||||
|
return new Unhandled(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
31
src/Core/Billing/Commands/BillingCommandResult.cs
Normal file
31
src/Core/Billing/Commands/BillingCommandResult.cs
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
#nullable enable
|
||||||
|
using OneOf;
|
||||||
|
|
||||||
|
namespace Bit.Core.Billing.Commands;
|
||||||
|
|
||||||
|
public record BadRequest(string Response);
|
||||||
|
public record Conflict(string Response);
|
||||||
|
public record Unhandled(Exception? Exception = null, string Response = "Something went wrong with your request. Please contact support for assistance.");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A <see cref="OneOf"/> union type representing the result of a billing command.
|
||||||
|
/// <remarks>
|
||||||
|
/// Choices include:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description><typeparamref name="T"/>: Success</description></item>
|
||||||
|
/// <item><description><see cref="BadRequest"/>: Invalid input</description></item>
|
||||||
|
/// <item><description><see cref="Conflict"/>: A known, but unresolvable issue</description></item>
|
||||||
|
/// <item><description><see cref="Unhandled"/>: An unknown issue</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </remarks>
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">The successful result type of the operation.</typeparam>
|
||||||
|
public class BillingCommandResult<T> : OneOfBase<T, BadRequest, Conflict, Unhandled>
|
||||||
|
{
|
||||||
|
private BillingCommandResult(OneOf<T, BadRequest, Conflict, Unhandled> input) : base(input) { }
|
||||||
|
|
||||||
|
public static implicit operator BillingCommandResult<T>(T output) => new(output);
|
||||||
|
public static implicit operator BillingCommandResult<T>(BadRequest badRequest) => new(badRequest);
|
||||||
|
public static implicit operator BillingCommandResult<T>(Conflict conflict) => new(conflict);
|
||||||
|
public static implicit operator BillingCommandResult<T>(Unhandled unhandled) => new(unhandled);
|
||||||
|
}
|
@ -1,4 +1,6 @@
|
|||||||
namespace Bit.Core.Billing.Constants;
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace Bit.Core.Billing.Constants;
|
||||||
|
|
||||||
public static class StripeConstants
|
public static class StripeConstants
|
||||||
{
|
{
|
||||||
@ -36,6 +38,13 @@ public static class StripeConstants
|
|||||||
public const string PaymentMethodMicroDepositVerificationDescriptorCodeMismatch = "payment_method_microdeposit_verification_descriptor_code_mismatch";
|
public const string PaymentMethodMicroDepositVerificationDescriptorCodeMismatch = "payment_method_microdeposit_verification_descriptor_code_mismatch";
|
||||||
public const string PaymentMethodMicroDepositVerificationTimeout = "payment_method_microdeposit_verification_timeout";
|
public const string PaymentMethodMicroDepositVerificationTimeout = "payment_method_microdeposit_verification_timeout";
|
||||||
public const string TaxIdInvalid = "tax_id_invalid";
|
public const string TaxIdInvalid = "tax_id_invalid";
|
||||||
|
|
||||||
|
public static string[] Get() =>
|
||||||
|
typeof(ErrorCodes)
|
||||||
|
.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy)
|
||||||
|
.Where(fi => fi is { IsLiteral: true, IsInitOnly: false } && fi.FieldType == typeof(string))
|
||||||
|
.Select(fi => (string)fi.GetValue(null)!)
|
||||||
|
.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class InvoiceStatus
|
public static class InvoiceStatus
|
||||||
@ -51,6 +60,7 @@ public static class StripeConstants
|
|||||||
public const string InvoiceApproved = "invoice_approved";
|
public const string InvoiceApproved = "invoice_approved";
|
||||||
public const string OrganizationId = "organizationId";
|
public const string OrganizationId = "organizationId";
|
||||||
public const string ProviderId = "providerId";
|
public const string ProviderId = "providerId";
|
||||||
|
public const string RetiredBraintreeCustomerId = "btCustomerId_old";
|
||||||
public const string UserId = "userId";
|
public const string UserId = "userId";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
using Bit.Core.Billing.Caches;
|
using Bit.Core.Billing.Caches;
|
||||||
using Bit.Core.Billing.Caches.Implementations;
|
using Bit.Core.Billing.Caches.Implementations;
|
||||||
using Bit.Core.Billing.Licenses.Extensions;
|
using Bit.Core.Billing.Licenses.Extensions;
|
||||||
|
using Bit.Core.Billing.Payment;
|
||||||
using Bit.Core.Billing.Pricing;
|
using Bit.Core.Billing.Pricing;
|
||||||
using Bit.Core.Billing.Services;
|
using Bit.Core.Billing.Services;
|
||||||
using Bit.Core.Billing.Services.Implementations;
|
using Bit.Core.Billing.Services.Implementations;
|
||||||
@ -27,5 +28,6 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddLicenseServices();
|
services.AddLicenseServices();
|
||||||
services.AddPricingClient();
|
services.AddPricingClient();
|
||||||
services.AddTransient<IPreviewTaxAmountCommand, PreviewTaxAmountCommand>();
|
services.AddTransient<IPreviewTaxAmountCommand, PreviewTaxAmountCommand>();
|
||||||
|
services.AddPaymentOperations();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,8 @@
|
|||||||
using Bit.Core.Entities;
|
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.Entities;
|
||||||
|
|
||||||
namespace Bit.Core.Billing.Extensions;
|
namespace Bit.Core.Billing.Extensions;
|
||||||
|
|
||||||
@ -23,4 +27,14 @@ public static class SubscriberExtensions
|
|||||||
? subscriberName
|
? subscriberName
|
||||||
: subscriberName[..30];
|
: subscriberName[..30];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static ProductUsageType GetProductUsageType(this ISubscriber subscriber)
|
||||||
|
=> subscriber switch
|
||||||
|
{
|
||||||
|
User => ProductUsageType.Personal,
|
||||||
|
Organization organization when organization.PlanType.GetProductTier() is ProductTierType.Free or ProductTierType.Families => ProductUsageType.Personal,
|
||||||
|
Organization => ProductUsageType.Business,
|
||||||
|
Provider => ProductUsageType.Business,
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(subscriber))
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,36 +0,0 @@
|
|||||||
using OneOf;
|
|
||||||
|
|
||||||
namespace Bit.Core.Billing.Models;
|
|
||||||
|
|
||||||
public record BadRequest(string TranslationKey)
|
|
||||||
{
|
|
||||||
public static BadRequest TaxIdNumberInvalid => new(BillingErrorTranslationKeys.TaxIdInvalid);
|
|
||||||
public static BadRequest TaxLocationInvalid => new(BillingErrorTranslationKeys.CustomerTaxLocationInvalid);
|
|
||||||
public static BadRequest UnknownTaxIdType => new(BillingErrorTranslationKeys.UnknownTaxIdType);
|
|
||||||
}
|
|
||||||
|
|
||||||
public record Unhandled(string TranslationKey = BillingErrorTranslationKeys.UnhandledError);
|
|
||||||
|
|
||||||
public class BillingCommandResult<T> : OneOfBase<T, BadRequest, Unhandled>
|
|
||||||
{
|
|
||||||
private BillingCommandResult(OneOf<T, BadRequest, Unhandled> input) : base(input) { }
|
|
||||||
|
|
||||||
public static implicit operator BillingCommandResult<T>(T output) => new(output);
|
|
||||||
public static implicit operator BillingCommandResult<T>(BadRequest badRequest) => new(badRequest);
|
|
||||||
public static implicit operator BillingCommandResult<T>(Unhandled unhandled) => new(unhandled);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class BillingErrorTranslationKeys
|
|
||||||
{
|
|
||||||
// "The tax ID number you provided was invalid. Please try again or contact support."
|
|
||||||
public const string TaxIdInvalid = "taxIdInvalid";
|
|
||||||
|
|
||||||
// "Your location wasn't recognized. Please ensure your country and postal code are valid and try again."
|
|
||||||
public const string CustomerTaxLocationInvalid = "customerTaxLocationInvalid";
|
|
||||||
|
|
||||||
// "Something went wrong with your request. Please contact support."
|
|
||||||
public const string UnhandledError = "unhandledBillingError";
|
|
||||||
|
|
||||||
// "We couldn't find a corresponding tax ID type for the tax ID you provided. Please try again or contact support."
|
|
||||||
public const string UnknownTaxIdType = "unknownTaxIdType";
|
|
||||||
}
|
|
24
src/Core/Billing/Payment/Clients/BitPayClient.cs
Normal file
24
src/Core/Billing/Payment/Clients/BitPayClient.cs
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
using Bit.Core.Settings;
|
||||||
|
using BitPayLight;
|
||||||
|
using BitPayLight.Models.Invoice;
|
||||||
|
|
||||||
|
namespace Bit.Core.Billing.Payment.Clients;
|
||||||
|
|
||||||
|
public interface IBitPayClient
|
||||||
|
{
|
||||||
|
Task<Invoice> GetInvoice(string invoiceId);
|
||||||
|
Task<Invoice> CreateInvoice(Invoice invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class BitPayClient(
|
||||||
|
GlobalSettings globalSettings) : IBitPayClient
|
||||||
|
{
|
||||||
|
private readonly BitPay _bitPay = new(
|
||||||
|
globalSettings.BitPay.Token, globalSettings.BitPay.Production ? Env.Prod : Env.Test);
|
||||||
|
|
||||||
|
public Task<Invoice> GetInvoice(string invoiceId)
|
||||||
|
=> _bitPay.GetInvoice(invoiceId);
|
||||||
|
|
||||||
|
public Task<Invoice> CreateInvoice(Invoice invoice)
|
||||||
|
=> _bitPay.CreateInvoice(invoice);
|
||||||
|
}
|
@ -0,0 +1,59 @@
|
|||||||
|
#nullable enable
|
||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
|
using Bit.Core.Billing.Commands;
|
||||||
|
using Bit.Core.Billing.Payment.Clients;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Settings;
|
||||||
|
using BitPayLight.Models.Invoice;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Bit.Core.Billing.Payment.Commands;
|
||||||
|
|
||||||
|
public interface ICreateBitPayInvoiceForCreditCommand
|
||||||
|
{
|
||||||
|
Task<BillingCommandResult<string>> Run(
|
||||||
|
ISubscriber subscriber,
|
||||||
|
decimal amount,
|
||||||
|
string redirectUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreateBitPayInvoiceForCreditCommand(
|
||||||
|
IBitPayClient bitPayClient,
|
||||||
|
GlobalSettings globalSettings,
|
||||||
|
ILogger<CreateBitPayInvoiceForCreditCommand> logger) : BillingCommand<CreateBitPayInvoiceForCreditCommand>(logger), ICreateBitPayInvoiceForCreditCommand
|
||||||
|
{
|
||||||
|
public Task<BillingCommandResult<string>> Run(
|
||||||
|
ISubscriber subscriber,
|
||||||
|
decimal amount,
|
||||||
|
string redirectUrl) => HandleAsync<string>(async () =>
|
||||||
|
{
|
||||||
|
var (name, email, posData) = GetSubscriberInformation(subscriber);
|
||||||
|
|
||||||
|
var invoice = new Invoice
|
||||||
|
{
|
||||||
|
Buyer = new Buyer { Email = email, Name = name },
|
||||||
|
Currency = "USD",
|
||||||
|
ExtendedNotifications = true,
|
||||||
|
FullNotifications = true,
|
||||||
|
ItemDesc = "Bitwarden",
|
||||||
|
NotificationUrl = globalSettings.BitPay.NotificationUrl,
|
||||||
|
PosData = posData,
|
||||||
|
Price = Convert.ToDouble(amount),
|
||||||
|
RedirectUrl = redirectUrl
|
||||||
|
};
|
||||||
|
|
||||||
|
var created = await bitPayClient.CreateInvoice(invoice);
|
||||||
|
return created.Url;
|
||||||
|
});
|
||||||
|
|
||||||
|
private static (string? Name, string? Email, string POSData) GetSubscriberInformation(
|
||||||
|
ISubscriber subscriber) => subscriber switch
|
||||||
|
{
|
||||||
|
User user => (user.Email, user.Email, $"userId:{user.Id},accountCredit:1"),
|
||||||
|
Organization organization => (organization.Name, organization.BillingEmail,
|
||||||
|
$"organizationId:{organization.Id},accountCredit:1"),
|
||||||
|
Provider provider => (provider.Name, provider.BillingEmail, $"providerId:{provider.Id},accountCredit:1"),
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(subscriber))
|
||||||
|
};
|
||||||
|
}
|
129
src/Core/Billing/Payment/Commands/UpdateBillingAddressCommand.cs
Normal file
129
src/Core/Billing/Payment/Commands/UpdateBillingAddressCommand.cs
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
#nullable enable
|
||||||
|
using Bit.Core.Billing.Commands;
|
||||||
|
using Bit.Core.Billing.Constants;
|
||||||
|
using Bit.Core.Billing.Extensions;
|
||||||
|
using Bit.Core.Billing.Payment.Models;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Stripe;
|
||||||
|
|
||||||
|
namespace Bit.Core.Billing.Payment.Commands;
|
||||||
|
|
||||||
|
public interface IUpdateBillingAddressCommand
|
||||||
|
{
|
||||||
|
Task<BillingCommandResult<BillingAddress>> Run(
|
||||||
|
ISubscriber subscriber,
|
||||||
|
BillingAddress billingAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdateBillingAddressCommand(
|
||||||
|
ILogger<UpdateBillingAddressCommand> logger,
|
||||||
|
IStripeAdapter stripeAdapter) : BillingCommand<UpdateBillingAddressCommand>(logger), IUpdateBillingAddressCommand
|
||||||
|
{
|
||||||
|
public Task<BillingCommandResult<BillingAddress>> Run(
|
||||||
|
ISubscriber subscriber,
|
||||||
|
BillingAddress billingAddress) => HandleAsync(() => subscriber.GetProductUsageType() switch
|
||||||
|
{
|
||||||
|
ProductUsageType.Personal => UpdatePersonalBillingAddressAsync(subscriber, billingAddress),
|
||||||
|
ProductUsageType.Business => UpdateBusinessBillingAddressAsync(subscriber, billingAddress)
|
||||||
|
});
|
||||||
|
|
||||||
|
private async Task<BillingCommandResult<BillingAddress>> UpdatePersonalBillingAddressAsync(
|
||||||
|
ISubscriber subscriber,
|
||||||
|
BillingAddress billingAddress)
|
||||||
|
{
|
||||||
|
var customer =
|
||||||
|
await stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId,
|
||||||
|
new CustomerUpdateOptions
|
||||||
|
{
|
||||||
|
Address = new AddressOptions
|
||||||
|
{
|
||||||
|
Country = billingAddress.Country,
|
||||||
|
PostalCode = billingAddress.PostalCode,
|
||||||
|
Line1 = billingAddress.Line1,
|
||||||
|
Line2 = billingAddress.Line2,
|
||||||
|
City = billingAddress.City,
|
||||||
|
State = billingAddress.State
|
||||||
|
},
|
||||||
|
Expand = ["subscriptions"]
|
||||||
|
});
|
||||||
|
|
||||||
|
await EnableAutomaticTaxAsync(subscriber, customer);
|
||||||
|
|
||||||
|
return BillingAddress.From(customer.Address);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<BillingCommandResult<BillingAddress>> UpdateBusinessBillingAddressAsync(
|
||||||
|
ISubscriber subscriber,
|
||||||
|
BillingAddress billingAddress)
|
||||||
|
{
|
||||||
|
var customer =
|
||||||
|
await stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId,
|
||||||
|
new CustomerUpdateOptions
|
||||||
|
{
|
||||||
|
Address = new AddressOptions
|
||||||
|
{
|
||||||
|
Country = billingAddress.Country,
|
||||||
|
PostalCode = billingAddress.PostalCode,
|
||||||
|
Line1 = billingAddress.Line1,
|
||||||
|
Line2 = billingAddress.Line2,
|
||||||
|
City = billingAddress.City,
|
||||||
|
State = billingAddress.State
|
||||||
|
},
|
||||||
|
Expand = ["subscriptions", "tax_ids"],
|
||||||
|
TaxExempt = billingAddress.Country != "US"
|
||||||
|
? StripeConstants.TaxExempt.Reverse
|
||||||
|
: StripeConstants.TaxExempt.None
|
||||||
|
});
|
||||||
|
|
||||||
|
await EnableAutomaticTaxAsync(subscriber, customer);
|
||||||
|
|
||||||
|
var deleteExistingTaxIds = customer.TaxIds?.Any() ?? false
|
||||||
|
? customer.TaxIds.Select(taxId => stripeAdapter.TaxIdDeleteAsync(customer.Id, taxId.Id)).ToList()
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (billingAddress.TaxId == null)
|
||||||
|
{
|
||||||
|
await Task.WhenAll(deleteExistingTaxIds);
|
||||||
|
return BillingAddress.From(customer.Address);
|
||||||
|
}
|
||||||
|
|
||||||
|
var updatedTaxId = await stripeAdapter.TaxIdCreateAsync(customer.Id,
|
||||||
|
new TaxIdCreateOptions { Type = billingAddress.TaxId.Code, Value = billingAddress.TaxId.Value });
|
||||||
|
|
||||||
|
if (billingAddress.TaxId.Code == StripeConstants.TaxIdType.SpanishNIF)
|
||||||
|
{
|
||||||
|
updatedTaxId = await stripeAdapter.TaxIdCreateAsync(customer.Id,
|
||||||
|
new TaxIdCreateOptions
|
||||||
|
{
|
||||||
|
Type = StripeConstants.TaxIdType.EUVAT,
|
||||||
|
Value = $"ES{billingAddress.TaxId.Value}"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.WhenAll(deleteExistingTaxIds);
|
||||||
|
|
||||||
|
return BillingAddress.From(customer.Address, updatedTaxId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnableAutomaticTaxAsync(
|
||||||
|
ISubscriber subscriber,
|
||||||
|
Customer customer)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(subscriber.GatewaySubscriptionId))
|
||||||
|
{
|
||||||
|
var subscription = customer.Subscriptions.FirstOrDefault(subscription =>
|
||||||
|
subscription.Id == subscriber.GatewaySubscriptionId);
|
||||||
|
|
||||||
|
if (subscription is { AutomaticTax.Enabled: false })
|
||||||
|
{
|
||||||
|
await stripeAdapter.SubscriptionUpdateAsync(subscriber.GatewaySubscriptionId,
|
||||||
|
new SubscriptionUpdateOptions
|
||||||
|
{
|
||||||
|
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
205
src/Core/Billing/Payment/Commands/UpdatePaymentMethodCommand.cs
Normal file
205
src/Core/Billing/Payment/Commands/UpdatePaymentMethodCommand.cs
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
#nullable enable
|
||||||
|
using Bit.Core.Billing.Caches;
|
||||||
|
using Bit.Core.Billing.Commands;
|
||||||
|
using Bit.Core.Billing.Constants;
|
||||||
|
using Bit.Core.Billing.Payment.Models;
|
||||||
|
using Bit.Core.Billing.Services;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Bit.Core.Settings;
|
||||||
|
using Bit.Core.Utilities;
|
||||||
|
using Braintree;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Stripe;
|
||||||
|
using Customer = Stripe.Customer;
|
||||||
|
|
||||||
|
namespace Bit.Core.Billing.Payment.Commands;
|
||||||
|
|
||||||
|
public interface IUpdatePaymentMethodCommand
|
||||||
|
{
|
||||||
|
Task<BillingCommandResult<MaskedPaymentMethod>> Run(
|
||||||
|
ISubscriber subscriber,
|
||||||
|
TokenizedPaymentMethod paymentMethod,
|
||||||
|
BillingAddress? billingAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdatePaymentMethodCommand(
|
||||||
|
IBraintreeGateway braintreeGateway,
|
||||||
|
IGlobalSettings globalSettings,
|
||||||
|
ILogger<UpdatePaymentMethodCommand> logger,
|
||||||
|
ISetupIntentCache setupIntentCache,
|
||||||
|
IStripeAdapter stripeAdapter,
|
||||||
|
ISubscriberService subscriberService) : BillingCommand<UpdatePaymentMethodCommand>(logger), IUpdatePaymentMethodCommand
|
||||||
|
{
|
||||||
|
private readonly ILogger<UpdatePaymentMethodCommand> _logger = logger;
|
||||||
|
private static readonly Conflict _conflict = new("We had a problem updating your payment method. Please contact support for assistance.");
|
||||||
|
|
||||||
|
public Task<BillingCommandResult<MaskedPaymentMethod>> Run(
|
||||||
|
ISubscriber subscriber,
|
||||||
|
TokenizedPaymentMethod paymentMethod,
|
||||||
|
BillingAddress? billingAddress) => HandleAsync(async () =>
|
||||||
|
{
|
||||||
|
var customer = await subscriberService.GetCustomer(subscriber);
|
||||||
|
|
||||||
|
var result = paymentMethod.Type switch
|
||||||
|
{
|
||||||
|
TokenizablePaymentMethodType.BankAccount => await AddBankAccountAsync(subscriber, customer, paymentMethod.Token),
|
||||||
|
TokenizablePaymentMethodType.Card => await AddCardAsync(customer, paymentMethod.Token),
|
||||||
|
TokenizablePaymentMethodType.PayPal => await AddPayPalAsync(subscriber, customer, paymentMethod.Token),
|
||||||
|
_ => new BadRequest($"Payment method type '{paymentMethod.Type}' is not supported.")
|
||||||
|
};
|
||||||
|
|
||||||
|
if (billingAddress != null && customer.Address is not { Country: not null, PostalCode: not null })
|
||||||
|
{
|
||||||
|
await stripeAdapter.CustomerUpdateAsync(customer.Id,
|
||||||
|
new CustomerUpdateOptions
|
||||||
|
{
|
||||||
|
Address = new AddressOptions
|
||||||
|
{
|
||||||
|
Country = billingAddress.Country,
|
||||||
|
PostalCode = billingAddress.PostalCode
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
private async Task<BillingCommandResult<MaskedPaymentMethod>> AddBankAccountAsync(
|
||||||
|
ISubscriber subscriber,
|
||||||
|
Customer customer,
|
||||||
|
string token)
|
||||||
|
{
|
||||||
|
var setupIntents = await stripeAdapter.SetupIntentList(new SetupIntentListOptions
|
||||||
|
{
|
||||||
|
Expand = ["data.payment_method"],
|
||||||
|
PaymentMethod = token
|
||||||
|
});
|
||||||
|
|
||||||
|
switch (setupIntents.Count)
|
||||||
|
{
|
||||||
|
case 0:
|
||||||
|
_logger.LogError("{Command}: Could not find setup intent for subscriber's ({SubscriberID}) bank account", CommandName, subscriber.Id);
|
||||||
|
return _conflict;
|
||||||
|
case > 1:
|
||||||
|
_logger.LogError("{Command}: Found more than one set up intent for subscriber's ({SubscriberID}) bank account", CommandName, subscriber.Id);
|
||||||
|
return _conflict;
|
||||||
|
}
|
||||||
|
|
||||||
|
var setupIntent = setupIntents.First();
|
||||||
|
|
||||||
|
await setupIntentCache.Set(subscriber.Id, setupIntent.Id);
|
||||||
|
|
||||||
|
await UnlinkBraintreeCustomerAsync(customer);
|
||||||
|
|
||||||
|
return MaskedPaymentMethod.From(setupIntent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<BillingCommandResult<MaskedPaymentMethod>> AddCardAsync(
|
||||||
|
Customer customer,
|
||||||
|
string token)
|
||||||
|
{
|
||||||
|
var paymentMethod = await stripeAdapter.PaymentMethodAttachAsync(token, new PaymentMethodAttachOptions { Customer = customer.Id });
|
||||||
|
|
||||||
|
await stripeAdapter.CustomerUpdateAsync(customer.Id,
|
||||||
|
new CustomerUpdateOptions
|
||||||
|
{
|
||||||
|
InvoiceSettings = new CustomerInvoiceSettingsOptions { DefaultPaymentMethod = token }
|
||||||
|
});
|
||||||
|
|
||||||
|
await UnlinkBraintreeCustomerAsync(customer);
|
||||||
|
|
||||||
|
return MaskedPaymentMethod.From(paymentMethod.Card);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<BillingCommandResult<MaskedPaymentMethod>> AddPayPalAsync(
|
||||||
|
ISubscriber subscriber,
|
||||||
|
Customer customer,
|
||||||
|
string token)
|
||||||
|
{
|
||||||
|
Braintree.Customer braintreeCustomer;
|
||||||
|
|
||||||
|
if (customer.Metadata.TryGetValue(StripeConstants.MetadataKeys.BraintreeCustomerId, out var braintreeCustomerId))
|
||||||
|
{
|
||||||
|
braintreeCustomer = await braintreeGateway.Customer.FindAsync(braintreeCustomerId);
|
||||||
|
|
||||||
|
await ReplaceBraintreePaymentMethodAsync(braintreeCustomer, token);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
braintreeCustomer = await CreateBraintreeCustomerAsync(subscriber, token);
|
||||||
|
|
||||||
|
var metadata = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
[StripeConstants.MetadataKeys.BraintreeCustomerId] = braintreeCustomer.Id
|
||||||
|
};
|
||||||
|
|
||||||
|
await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions { Metadata = metadata });
|
||||||
|
}
|
||||||
|
|
||||||
|
var payPalAccount = braintreeCustomer.DefaultPaymentMethod as PayPalAccount;
|
||||||
|
|
||||||
|
return MaskedPaymentMethod.From(payPalAccount!);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Braintree.Customer> CreateBraintreeCustomerAsync(
|
||||||
|
ISubscriber subscriber,
|
||||||
|
string token)
|
||||||
|
{
|
||||||
|
var braintreeCustomerId =
|
||||||
|
subscriber.BraintreeCustomerIdPrefix() +
|
||||||
|
subscriber.Id.ToString("N").ToLower() +
|
||||||
|
CoreHelpers.RandomString(3, upper: false, numeric: false);
|
||||||
|
|
||||||
|
var result = await braintreeGateway.Customer.CreateAsync(new CustomerRequest
|
||||||
|
{
|
||||||
|
Id = braintreeCustomerId,
|
||||||
|
CustomFields = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
[subscriber.BraintreeIdField()] = subscriber.Id.ToString(),
|
||||||
|
[subscriber.BraintreeCloudRegionField()] = globalSettings.BaseServiceUri.CloudRegion
|
||||||
|
},
|
||||||
|
Email = subscriber.BillingEmailAddress(),
|
||||||
|
PaymentMethodNonce = token
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.Target;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReplaceBraintreePaymentMethodAsync(
|
||||||
|
Braintree.Customer customer,
|
||||||
|
string token)
|
||||||
|
{
|
||||||
|
var existing = customer.DefaultPaymentMethod;
|
||||||
|
|
||||||
|
var result = await braintreeGateway.PaymentMethod.CreateAsync(new PaymentMethodRequest
|
||||||
|
{
|
||||||
|
CustomerId = customer.Id,
|
||||||
|
PaymentMethodNonce = token
|
||||||
|
});
|
||||||
|
|
||||||
|
await braintreeGateway.Customer.UpdateAsync(
|
||||||
|
customer.Id,
|
||||||
|
new CustomerRequest { DefaultPaymentMethodToken = result.Target.Token });
|
||||||
|
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
await braintreeGateway.PaymentMethod.DeleteAsync(existing.Token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UnlinkBraintreeCustomerAsync(
|
||||||
|
Customer customer)
|
||||||
|
{
|
||||||
|
if (customer.Metadata.TryGetValue(StripeConstants.MetadataKeys.BraintreeCustomerId, out var braintreeCustomerId))
|
||||||
|
{
|
||||||
|
var metadata = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
[StripeConstants.MetadataKeys.RetiredBraintreeCustomerId] = braintreeCustomerId,
|
||||||
|
[StripeConstants.MetadataKeys.BraintreeCustomerId] = string.Empty
|
||||||
|
};
|
||||||
|
|
||||||
|
await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions { Metadata = metadata });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,63 @@
|
|||||||
|
#nullable enable
|
||||||
|
using Bit.Core.Billing.Caches;
|
||||||
|
using Bit.Core.Billing.Commands;
|
||||||
|
using Bit.Core.Billing.Payment.Models;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Stripe;
|
||||||
|
|
||||||
|
namespace Bit.Core.Billing.Payment.Commands;
|
||||||
|
|
||||||
|
public interface IVerifyBankAccountCommand
|
||||||
|
{
|
||||||
|
Task<BillingCommandResult<MaskedPaymentMethod>> Run(
|
||||||
|
ISubscriber subscriber,
|
||||||
|
string descriptorCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class VerifyBankAccountCommand(
|
||||||
|
ILogger<VerifyBankAccountCommand> logger,
|
||||||
|
ISetupIntentCache setupIntentCache,
|
||||||
|
IStripeAdapter stripeAdapter) : BillingCommand<VerifyBankAccountCommand>(logger), IVerifyBankAccountCommand
|
||||||
|
{
|
||||||
|
private readonly ILogger<VerifyBankAccountCommand> _logger = logger;
|
||||||
|
|
||||||
|
private static readonly Conflict _conflict =
|
||||||
|
new("We had a problem verifying your bank account. Please contact support for assistance.");
|
||||||
|
|
||||||
|
public Task<BillingCommandResult<MaskedPaymentMethod>> Run(
|
||||||
|
ISubscriber subscriber,
|
||||||
|
string descriptorCode) => HandleAsync<MaskedPaymentMethod>(async () =>
|
||||||
|
{
|
||||||
|
var setupIntentId = await setupIntentCache.Get(subscriber.Id);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(setupIntentId))
|
||||||
|
{
|
||||||
|
_logger.LogError(
|
||||||
|
"{Command}: Could not find setup intent to verify subscriber's ({SubscriberID}) bank account",
|
||||||
|
CommandName, subscriber.Id);
|
||||||
|
return _conflict;
|
||||||
|
}
|
||||||
|
|
||||||
|
await stripeAdapter.SetupIntentVerifyMicroDeposit(setupIntentId,
|
||||||
|
new SetupIntentVerifyMicrodepositsOptions { DescriptorCode = descriptorCode });
|
||||||
|
|
||||||
|
var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId,
|
||||||
|
new SetupIntentGetOptions { Expand = ["payment_method"] });
|
||||||
|
|
||||||
|
var paymentMethod = await stripeAdapter.PaymentMethodAttachAsync(setupIntent.PaymentMethodId,
|
||||||
|
new PaymentMethodAttachOptions { Customer = subscriber.GatewayCustomerId });
|
||||||
|
|
||||||
|
await stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId,
|
||||||
|
new CustomerUpdateOptions
|
||||||
|
{
|
||||||
|
InvoiceSettings = new CustomerInvoiceSettingsOptions
|
||||||
|
{
|
||||||
|
DefaultPaymentMethod = setupIntent.PaymentMethodId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return MaskedPaymentMethod.From(paymentMethod.UsBankAccount);
|
||||||
|
});
|
||||||
|
}
|
30
src/Core/Billing/Payment/Models/BillingAddress.cs
Normal file
30
src/Core/Billing/Payment/Models/BillingAddress.cs
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
#nullable enable
|
||||||
|
using Stripe;
|
||||||
|
|
||||||
|
namespace Bit.Core.Billing.Payment.Models;
|
||||||
|
|
||||||
|
public record TaxID(string Code, string Value);
|
||||||
|
|
||||||
|
public record BillingAddress
|
||||||
|
{
|
||||||
|
public required string Country { get; set; }
|
||||||
|
public required string PostalCode { get; set; }
|
||||||
|
public string? Line1 { get; set; }
|
||||||
|
public string? Line2 { get; set; }
|
||||||
|
public string? City { get; set; }
|
||||||
|
public string? State { get; set; }
|
||||||
|
public TaxID? TaxId { get; set; }
|
||||||
|
|
||||||
|
public static BillingAddress From(Address address) => new()
|
||||||
|
{
|
||||||
|
Country = address.Country,
|
||||||
|
PostalCode = address.PostalCode,
|
||||||
|
Line1 = address.Line1,
|
||||||
|
Line2 = address.Line2,
|
||||||
|
City = address.City,
|
||||||
|
State = address.State
|
||||||
|
};
|
||||||
|
|
||||||
|
public static BillingAddress From(Address address, TaxId? taxId) =>
|
||||||
|
From(address) with { TaxId = taxId != null ? new TaxID(taxId.Type, taxId.Value) : null };
|
||||||
|
}
|
120
src/Core/Billing/Payment/Models/MaskedPaymentMethod.cs
Normal file
120
src/Core/Billing/Payment/Models/MaskedPaymentMethod.cs
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Bit.Core.Billing.Pricing.JSON;
|
||||||
|
using Braintree;
|
||||||
|
using OneOf;
|
||||||
|
using Stripe;
|
||||||
|
|
||||||
|
namespace Bit.Core.Billing.Payment.Models;
|
||||||
|
|
||||||
|
public record MaskedBankAccount
|
||||||
|
{
|
||||||
|
public required string BankName { get; init; }
|
||||||
|
public required string Last4 { get; init; }
|
||||||
|
public required bool Verified { get; init; }
|
||||||
|
public string Type => "bankAccount";
|
||||||
|
}
|
||||||
|
|
||||||
|
public record MaskedCard
|
||||||
|
{
|
||||||
|
public required string Brand { get; init; }
|
||||||
|
public required string Last4 { get; init; }
|
||||||
|
public required string Expiration { get; init; }
|
||||||
|
public string Type => "card";
|
||||||
|
}
|
||||||
|
|
||||||
|
public record MaskedPayPalAccount
|
||||||
|
{
|
||||||
|
public required string Email { get; init; }
|
||||||
|
public string Type => "payPal";
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonConverter(typeof(MaskedPaymentMethodJsonConverter))]
|
||||||
|
public class MaskedPaymentMethod(OneOf<MaskedBankAccount, MaskedCard, MaskedPayPalAccount> input)
|
||||||
|
: OneOfBase<MaskedBankAccount, MaskedCard, MaskedPayPalAccount>(input)
|
||||||
|
{
|
||||||
|
public static implicit operator MaskedPaymentMethod(MaskedBankAccount bankAccount) => new(bankAccount);
|
||||||
|
public static implicit operator MaskedPaymentMethod(MaskedCard card) => new(card);
|
||||||
|
public static implicit operator MaskedPaymentMethod(MaskedPayPalAccount payPal) => new(payPal);
|
||||||
|
|
||||||
|
public static MaskedPaymentMethod From(BankAccount bankAccount) => new MaskedBankAccount
|
||||||
|
{
|
||||||
|
BankName = bankAccount.BankName,
|
||||||
|
Last4 = bankAccount.Last4,
|
||||||
|
Verified = bankAccount.Status == "verified"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static MaskedPaymentMethod From(Card card) => new MaskedCard
|
||||||
|
{
|
||||||
|
Brand = card.Brand.ToLower(),
|
||||||
|
Last4 = card.Last4,
|
||||||
|
Expiration = $"{card.ExpMonth:00}/{card.ExpYear}"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static MaskedPaymentMethod From(PaymentMethodCard card) => new MaskedCard
|
||||||
|
{
|
||||||
|
Brand = card.Brand.ToLower(),
|
||||||
|
Last4 = card.Last4,
|
||||||
|
Expiration = $"{card.ExpMonth:00}/{card.ExpYear}"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static MaskedPaymentMethod From(SetupIntent setupIntent) => new MaskedBankAccount
|
||||||
|
{
|
||||||
|
BankName = setupIntent.PaymentMethod.UsBankAccount.BankName,
|
||||||
|
Last4 = setupIntent.PaymentMethod.UsBankAccount.Last4,
|
||||||
|
Verified = false
|
||||||
|
};
|
||||||
|
|
||||||
|
public static MaskedPaymentMethod From(SourceCard sourceCard) => new MaskedCard
|
||||||
|
{
|
||||||
|
Brand = sourceCard.Brand.ToLower(),
|
||||||
|
Last4 = sourceCard.Last4,
|
||||||
|
Expiration = $"{sourceCard.ExpMonth:00}/{sourceCard.ExpYear}"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static MaskedPaymentMethod From(PaymentMethodUsBankAccount bankAccount) => new MaskedBankAccount
|
||||||
|
{
|
||||||
|
BankName = bankAccount.BankName,
|
||||||
|
Last4 = bankAccount.Last4,
|
||||||
|
Verified = true
|
||||||
|
};
|
||||||
|
|
||||||
|
public static MaskedPaymentMethod From(PayPalAccount payPalAccount) => new MaskedPayPalAccount { Email = payPalAccount.Email };
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MaskedPaymentMethodJsonConverter : TypeReadingJsonConverter<MaskedPaymentMethod>
|
||||||
|
{
|
||||||
|
protected override string TypePropertyName => nameof(MaskedBankAccount.Type).ToLower();
|
||||||
|
|
||||||
|
public override MaskedPaymentMethod? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
var type = ReadType(reader);
|
||||||
|
|
||||||
|
return type switch
|
||||||
|
{
|
||||||
|
"bankAccount" => JsonSerializer.Deserialize<MaskedBankAccount>(ref reader, options) switch
|
||||||
|
{
|
||||||
|
null => null,
|
||||||
|
var bankAccount => new MaskedPaymentMethod(bankAccount)
|
||||||
|
},
|
||||||
|
"card" => JsonSerializer.Deserialize<MaskedCard>(ref reader, options) switch
|
||||||
|
{
|
||||||
|
null => null,
|
||||||
|
var card => new MaskedPaymentMethod(card)
|
||||||
|
},
|
||||||
|
"payPal" => JsonSerializer.Deserialize<MaskedPayPalAccount>(ref reader, options) switch
|
||||||
|
{
|
||||||
|
null => null,
|
||||||
|
var payPal => new MaskedPaymentMethod(payPal)
|
||||||
|
},
|
||||||
|
_ => Skip(ref reader)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Write(Utf8JsonWriter writer, MaskedPaymentMethod value, JsonSerializerOptions options)
|
||||||
|
=> value.Switch(
|
||||||
|
bankAccount => JsonSerializer.Serialize(writer, bankAccount, options),
|
||||||
|
card => JsonSerializer.Serialize(writer, card, options),
|
||||||
|
payPal => JsonSerializer.Serialize(writer, payPal, options));
|
||||||
|
}
|
7
src/Core/Billing/Payment/Models/ProductUsageType.cs
Normal file
7
src/Core/Billing/Payment/Models/ProductUsageType.cs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
namespace Bit.Core.Billing.Payment.Models;
|
||||||
|
|
||||||
|
public enum ProductUsageType
|
||||||
|
{
|
||||||
|
Personal,
|
||||||
|
Business
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
namespace Bit.Core.Billing.Payment.Models;
|
||||||
|
|
||||||
|
public enum TokenizablePaymentMethodType
|
||||||
|
{
|
||||||
|
BankAccount,
|
||||||
|
Card,
|
||||||
|
PayPal
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
#nullable enable
|
||||||
|
namespace Bit.Core.Billing.Payment.Models;
|
||||||
|
|
||||||
|
public record TokenizedPaymentMethod
|
||||||
|
{
|
||||||
|
public required TokenizablePaymentMethodType Type { get; set; }
|
||||||
|
public required string Token { get; set; }
|
||||||
|
}
|
41
src/Core/Billing/Payment/Queries/GetBillingAddressQuery.cs
Normal file
41
src/Core/Billing/Payment/Queries/GetBillingAddressQuery.cs
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
#nullable enable
|
||||||
|
using Bit.Core.Billing.Extensions;
|
||||||
|
using Bit.Core.Billing.Payment.Models;
|
||||||
|
using Bit.Core.Billing.Services;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
using Stripe;
|
||||||
|
|
||||||
|
namespace Bit.Core.Billing.Payment.Queries;
|
||||||
|
|
||||||
|
public interface IGetBillingAddressQuery
|
||||||
|
{
|
||||||
|
Task<BillingAddress?> Run(ISubscriber subscriber);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GetBillingAddressQuery(
|
||||||
|
ISubscriberService subscriberService) : IGetBillingAddressQuery
|
||||||
|
{
|
||||||
|
public async Task<BillingAddress?> Run(ISubscriber subscriber)
|
||||||
|
{
|
||||||
|
var productUsageType = subscriber.GetProductUsageType();
|
||||||
|
|
||||||
|
var options = productUsageType switch
|
||||||
|
{
|
||||||
|
ProductUsageType.Business => new CustomerGetOptions { Expand = ["tax_ids"] },
|
||||||
|
_ => new CustomerGetOptions()
|
||||||
|
};
|
||||||
|
|
||||||
|
var customer = await subscriberService.GetCustomer(subscriber, options);
|
||||||
|
|
||||||
|
if (customer is not { Address: { Country: not null, PostalCode: not null } })
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var taxId = productUsageType == ProductUsageType.Business ? customer.TaxIds?.FirstOrDefault() : null;
|
||||||
|
|
||||||
|
return taxId != null
|
||||||
|
? BillingAddress.From(customer.Address, taxId)
|
||||||
|
: BillingAddress.From(customer.Address);
|
||||||
|
}
|
||||||
|
}
|
26
src/Core/Billing/Payment/Queries/GetCreditQuery.cs
Normal file
26
src/Core/Billing/Payment/Queries/GetCreditQuery.cs
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
#nullable enable
|
||||||
|
using Bit.Core.Billing.Services;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
|
||||||
|
namespace Bit.Core.Billing.Payment.Queries;
|
||||||
|
|
||||||
|
public interface IGetCreditQuery
|
||||||
|
{
|
||||||
|
Task<decimal?> Run(ISubscriber subscriber);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GetCreditQuery(
|
||||||
|
ISubscriberService subscriberService) : IGetCreditQuery
|
||||||
|
{
|
||||||
|
public async Task<decimal?> Run(ISubscriber subscriber)
|
||||||
|
{
|
||||||
|
var customer = await subscriberService.GetCustomer(subscriber);
|
||||||
|
|
||||||
|
if (customer == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Convert.ToDecimal(customer.Balance) * -1 / 100;
|
||||||
|
}
|
||||||
|
}
|
96
src/Core/Billing/Payment/Queries/GetPaymentMethodQuery.cs
Normal file
96
src/Core/Billing/Payment/Queries/GetPaymentMethodQuery.cs
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
#nullable enable
|
||||||
|
using Bit.Core.Billing.Caches;
|
||||||
|
using Bit.Core.Billing.Constants;
|
||||||
|
using Bit.Core.Billing.Extensions;
|
||||||
|
using Bit.Core.Billing.Payment.Models;
|
||||||
|
using Bit.Core.Billing.Services;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Braintree;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Stripe;
|
||||||
|
|
||||||
|
namespace Bit.Core.Billing.Payment.Queries;
|
||||||
|
|
||||||
|
public interface IGetPaymentMethodQuery
|
||||||
|
{
|
||||||
|
Task<MaskedPaymentMethod?> Run(ISubscriber subscriber);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GetPaymentMethodQuery(
|
||||||
|
IBraintreeGateway braintreeGateway,
|
||||||
|
ILogger<GetPaymentMethodQuery> logger,
|
||||||
|
ISetupIntentCache setupIntentCache,
|
||||||
|
IStripeAdapter stripeAdapter,
|
||||||
|
ISubscriberService subscriberService) : IGetPaymentMethodQuery
|
||||||
|
{
|
||||||
|
public async Task<MaskedPaymentMethod?> Run(ISubscriber subscriber)
|
||||||
|
{
|
||||||
|
var customer = await subscriberService.GetCustomer(subscriber,
|
||||||
|
new CustomerGetOptions { Expand = ["default_source", "invoice_settings.default_payment_method"] });
|
||||||
|
|
||||||
|
if (customer.Metadata.TryGetValue(StripeConstants.MetadataKeys.BraintreeCustomerId, out var braintreeCustomerId))
|
||||||
|
{
|
||||||
|
var braintreeCustomer = await braintreeGateway.Customer.FindAsync(braintreeCustomerId);
|
||||||
|
|
||||||
|
if (braintreeCustomer.DefaultPaymentMethod is PayPalAccount payPalAccount)
|
||||||
|
{
|
||||||
|
return new MaskedPayPalAccount { Email = payPalAccount.Email };
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogWarning("Subscriber ({SubscriberID}) has a linked Braintree customer ({BraintreeCustomerId}) with no PayPal account.", subscriber.Id, braintreeCustomerId);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var paymentMethod = customer.InvoiceSettings.DefaultPaymentMethod != null
|
||||||
|
? customer.InvoiceSettings.DefaultPaymentMethod.Type switch
|
||||||
|
{
|
||||||
|
"card" => MaskedPaymentMethod.From(customer.InvoiceSettings.DefaultPaymentMethod.Card),
|
||||||
|
"us_bank_account" => MaskedPaymentMethod.From(customer.InvoiceSettings.DefaultPaymentMethod.UsBankAccount),
|
||||||
|
_ => null
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (paymentMethod != null)
|
||||||
|
{
|
||||||
|
return paymentMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customer.DefaultSource != null)
|
||||||
|
{
|
||||||
|
paymentMethod = customer.DefaultSource switch
|
||||||
|
{
|
||||||
|
Card card => MaskedPaymentMethod.From(card),
|
||||||
|
BankAccount bankAccount => MaskedPaymentMethod.From(bankAccount),
|
||||||
|
Source { Card: not null } source => MaskedPaymentMethod.From(source.Card),
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (paymentMethod != null)
|
||||||
|
{
|
||||||
|
return paymentMethod;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var setupIntentId = await setupIntentCache.Get(subscriber.Id);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(setupIntentId))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions
|
||||||
|
{
|
||||||
|
Expand = ["payment_method"]
|
||||||
|
});
|
||||||
|
|
||||||
|
// ReSharper disable once ConvertIfStatementToReturnStatement
|
||||||
|
if (!setupIntent.IsUnverifiedBankAccount())
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return MaskedPaymentMethod.From(setupIntent);
|
||||||
|
}
|
||||||
|
}
|
24
src/Core/Billing/Payment/Registrations.cs
Normal file
24
src/Core/Billing/Payment/Registrations.cs
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
using Bit.Core.Billing.Payment.Clients;
|
||||||
|
using Bit.Core.Billing.Payment.Commands;
|
||||||
|
using Bit.Core.Billing.Payment.Queries;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace Bit.Core.Billing.Payment;
|
||||||
|
|
||||||
|
public static class Registrations
|
||||||
|
{
|
||||||
|
public static void AddPaymentOperations(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
// Commands
|
||||||
|
services.AddTransient<IBitPayClient, BitPayClient>();
|
||||||
|
services.AddTransient<ICreateBitPayInvoiceForCreditCommand, CreateBitPayInvoiceForCreditCommand>();
|
||||||
|
services.AddTransient<IUpdateBillingAddressCommand, UpdateBillingAddressCommand>();
|
||||||
|
services.AddTransient<IUpdatePaymentMethodCommand, UpdatePaymentMethodCommand>();
|
||||||
|
services.AddTransient<IVerifyBankAccountCommand, VerifyBankAccountCommand>();
|
||||||
|
|
||||||
|
// Queries
|
||||||
|
services.AddTransient<IGetBillingAddressQuery, GetBillingAddressQuery>();
|
||||||
|
services.AddTransient<IGetCreditQuery, GetCreditQuery>();
|
||||||
|
services.AddTransient<IGetPaymentMethodQuery, GetPaymentMethodQuery>();
|
||||||
|
}
|
||||||
|
}
|
@ -6,7 +6,7 @@ namespace Bit.Core.Billing.Pricing.JSON;
|
|||||||
|
|
||||||
#nullable enable
|
#nullable enable
|
||||||
|
|
||||||
public abstract class TypeReadingJsonConverter<T> : JsonConverter<T>
|
public abstract class TypeReadingJsonConverter<T> : JsonConverter<T> where T : class
|
||||||
{
|
{
|
||||||
protected virtual string TypePropertyName => nameof(ScalableDTO.Type).ToLower();
|
protected virtual string TypePropertyName => nameof(ScalableDTO.Type).ToLower();
|
||||||
|
|
||||||
@ -14,7 +14,9 @@ public abstract class TypeReadingJsonConverter<T> : JsonConverter<T>
|
|||||||
{
|
{
|
||||||
while (reader.Read())
|
while (reader.Read())
|
||||||
{
|
{
|
||||||
if (reader.TokenType != JsonTokenType.PropertyName || reader.GetString()?.ToLower() != TypePropertyName)
|
if (reader.CurrentDepth != 1 ||
|
||||||
|
reader.TokenType != JsonTokenType.PropertyName ||
|
||||||
|
reader.GetString()?.ToLower() != TypePropertyName)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -25,4 +27,10 @@ public abstract class TypeReadingJsonConverter<T> : JsonConverter<T>
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected T? Skip(ref Utf8JsonReader reader)
|
||||||
|
{
|
||||||
|
reader.Skip();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
#nullable enable
|
#nullable enable
|
||||||
|
using Bit.Core.Billing.Commands;
|
||||||
using Bit.Core.Billing.Constants;
|
using Bit.Core.Billing.Constants;
|
||||||
using Bit.Core.Billing.Enums;
|
using Bit.Core.Billing.Enums;
|
||||||
using Bit.Core.Billing.Extensions;
|
using Bit.Core.Billing.Extensions;
|
||||||
using Bit.Core.Billing.Models;
|
|
||||||
using Bit.Core.Billing.Pricing;
|
using Bit.Core.Billing.Pricing;
|
||||||
using Bit.Core.Billing.Tax.Services;
|
using Bit.Core.Billing.Tax.Services;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
@ -20,111 +20,95 @@ public class PreviewTaxAmountCommand(
|
|||||||
ILogger<PreviewTaxAmountCommand> logger,
|
ILogger<PreviewTaxAmountCommand> logger,
|
||||||
IPricingClient pricingClient,
|
IPricingClient pricingClient,
|
||||||
IStripeAdapter stripeAdapter,
|
IStripeAdapter stripeAdapter,
|
||||||
ITaxService taxService) : IPreviewTaxAmountCommand
|
ITaxService taxService) : BillingCommand<PreviewTaxAmountCommand>(logger), IPreviewTaxAmountCommand
|
||||||
{
|
{
|
||||||
public async Task<BillingCommandResult<decimal>> Run(OrganizationTrialParameters parameters)
|
public Task<BillingCommandResult<decimal>> Run(OrganizationTrialParameters parameters)
|
||||||
{
|
=> HandleAsync<decimal>(async () =>
|
||||||
var (planType, productType, taxInformation) = parameters;
|
|
||||||
|
|
||||||
var plan = await pricingClient.GetPlanOrThrow(planType);
|
|
||||||
|
|
||||||
var options = new InvoiceCreatePreviewOptions
|
|
||||||
{
|
{
|
||||||
Currency = "usd",
|
var (planType, productType, taxInformation) = parameters;
|
||||||
CustomerDetails = new InvoiceCustomerDetailsOptions
|
|
||||||
|
var plan = await pricingClient.GetPlanOrThrow(planType);
|
||||||
|
|
||||||
|
var options = new InvoiceCreatePreviewOptions
|
||||||
{
|
{
|
||||||
Address = new AddressOptions
|
Currency = "usd",
|
||||||
|
CustomerDetails = new InvoiceCustomerDetailsOptions
|
||||||
{
|
{
|
||||||
Country = taxInformation.Country,
|
Address = new AddressOptions
|
||||||
PostalCode = taxInformation.PostalCode
|
|
||||||
}
|
|
||||||
},
|
|
||||||
SubscriptionDetails = new InvoiceSubscriptionDetailsOptions
|
|
||||||
{
|
|
||||||
Items = [
|
|
||||||
new InvoiceSubscriptionDetailsItemOptions
|
|
||||||
{
|
{
|
||||||
Price = plan.HasNonSeatBasedPasswordManagerPlan() ? plan.PasswordManager.StripePlanId : plan.PasswordManager.StripeSeatPlanId,
|
Country = taxInformation.Country,
|
||||||
Quantity = 1
|
PostalCode = taxInformation.PostalCode
|
||||||
}
|
}
|
||||||
]
|
},
|
||||||
}
|
SubscriptionDetails = new InvoiceSubscriptionDetailsOptions
|
||||||
};
|
|
||||||
|
|
||||||
if (productType == ProductType.SecretsManager)
|
|
||||||
{
|
|
||||||
options.SubscriptionDetails.Items.Add(new InvoiceSubscriptionDetailsItemOptions
|
|
||||||
{
|
|
||||||
Price = plan.SecretsManager.StripeSeatPlanId,
|
|
||||||
Quantity = 1
|
|
||||||
});
|
|
||||||
|
|
||||||
options.Coupon = StripeConstants.CouponIDs.SecretsManagerStandalone;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(taxInformation.TaxId))
|
|
||||||
{
|
|
||||||
var taxIdType = taxService.GetStripeTaxCode(
|
|
||||||
taxInformation.Country,
|
|
||||||
taxInformation.TaxId);
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(taxIdType))
|
|
||||||
{
|
|
||||||
return BadRequest.UnknownTaxIdType;
|
|
||||||
}
|
|
||||||
|
|
||||||
options.CustomerDetails.TaxIds = [
|
|
||||||
new InvoiceCustomerDetailsTaxIdOptions
|
|
||||||
{
|
{
|
||||||
Type = taxIdType,
|
Items =
|
||||||
Value = taxInformation.TaxId
|
[
|
||||||
|
new InvoiceSubscriptionDetailsItemOptions
|
||||||
|
{
|
||||||
|
Price = plan.HasNonSeatBasedPasswordManagerPlan()
|
||||||
|
? plan.PasswordManager.StripePlanId
|
||||||
|
: plan.PasswordManager.StripeSeatPlanId,
|
||||||
|
Quantity = 1
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
];
|
|
||||||
|
|
||||||
if (taxIdType == StripeConstants.TaxIdType.SpanishNIF)
|
|
||||||
{
|
|
||||||
options.CustomerDetails.TaxIds.Add(new InvoiceCustomerDetailsTaxIdOptions
|
|
||||||
{
|
|
||||||
Type = StripeConstants.TaxIdType.EUVAT,
|
|
||||||
Value = $"ES{parameters.TaxInformation.TaxId}"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (planType.GetProductTier() == ProductTierType.Families)
|
|
||||||
{
|
|
||||||
options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true };
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
options.AutomaticTax = new InvoiceAutomaticTaxOptions
|
|
||||||
{
|
|
||||||
Enabled = options.CustomerDetails.Address.Country == "US" ||
|
|
||||||
options.CustomerDetails.TaxIds is [_, ..]
|
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
try
|
if (productType == ProductType.SecretsManager)
|
||||||
{
|
{
|
||||||
|
options.SubscriptionDetails.Items.Add(new InvoiceSubscriptionDetailsItemOptions
|
||||||
|
{
|
||||||
|
Price = plan.SecretsManager.StripeSeatPlanId,
|
||||||
|
Quantity = 1
|
||||||
|
});
|
||||||
|
|
||||||
|
options.Coupon = StripeConstants.CouponIDs.SecretsManagerStandalone;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(taxInformation.TaxId))
|
||||||
|
{
|
||||||
|
var taxIdType = taxService.GetStripeTaxCode(
|
||||||
|
taxInformation.Country,
|
||||||
|
taxInformation.TaxId);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(taxIdType))
|
||||||
|
{
|
||||||
|
return new BadRequest(
|
||||||
|
"We couldn't find a corresponding tax ID type for the tax ID you provided. Please try again or contact support for assistance.");
|
||||||
|
}
|
||||||
|
|
||||||
|
options.CustomerDetails.TaxIds =
|
||||||
|
[
|
||||||
|
new InvoiceCustomerDetailsTaxIdOptions { Type = taxIdType, Value = taxInformation.TaxId }
|
||||||
|
];
|
||||||
|
|
||||||
|
if (taxIdType == StripeConstants.TaxIdType.SpanishNIF)
|
||||||
|
{
|
||||||
|
options.CustomerDetails.TaxIds.Add(new InvoiceCustomerDetailsTaxIdOptions
|
||||||
|
{
|
||||||
|
Type = StripeConstants.TaxIdType.EUVAT,
|
||||||
|
Value = $"ES{parameters.TaxInformation.TaxId}"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (planType.GetProductTier() == ProductTierType.Families)
|
||||||
|
{
|
||||||
|
options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true };
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
options.AutomaticTax = new InvoiceAutomaticTaxOptions
|
||||||
|
{
|
||||||
|
Enabled = options.CustomerDetails.Address.Country == "US" ||
|
||||||
|
options.CustomerDetails.TaxIds is [_, ..]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options);
|
var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options);
|
||||||
return Convert.ToDecimal(invoice.Tax) / 100;
|
return Convert.ToDecimal(invoice.Tax) / 100;
|
||||||
}
|
});
|
||||||
catch (StripeException stripeException) when (stripeException.StripeError.Code ==
|
|
||||||
StripeConstants.ErrorCodes.CustomerTaxLocationInvalid)
|
|
||||||
{
|
|
||||||
return BadRequest.TaxLocationInvalid;
|
|
||||||
}
|
|
||||||
catch (StripeException stripeException) when (stripeException.StripeError.Code ==
|
|
||||||
StripeConstants.ErrorCodes.TaxIdInvalid)
|
|
||||||
{
|
|
||||||
return BadRequest.TaxIdNumberInvalid;
|
|
||||||
}
|
|
||||||
catch (StripeException stripeException)
|
|
||||||
{
|
|
||||||
logger.LogError(stripeException, "Stripe responded with an error during {Operation}. Code: {Code}", nameof(PreviewTaxAmountCommand), stripeException.StripeError.Code);
|
|
||||||
return new Unhandled();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#region Command Parameters
|
#region Command Parameters
|
||||||
|
@ -155,6 +155,7 @@ public static class FeatureFlagKeys
|
|||||||
public const string PM20322_AllowTrialLength0 = "pm-20322-allow-trial-length-0";
|
public const string PM20322_AllowTrialLength0 = "pm-20322-allow-trial-length-0";
|
||||||
public const string PM21092_SetNonUSBusinessUseToReverseCharge = "pm-21092-set-non-us-business-use-to-reverse-charge";
|
public const string PM21092_SetNonUSBusinessUseToReverseCharge = "pm-21092-set-non-us-business-use-to-reverse-charge";
|
||||||
public const string PM21383_GetProviderPriceFromStripe = "pm-21383-get-provider-price-from-stripe";
|
public const string PM21383_GetProviderPriceFromStripe = "pm-21383-get-provider-price-from-stripe";
|
||||||
|
public const string PM21881_ManagePaymentDetailsOutsideCheckout = "pm-21881-manage-payment-details-outside-checkout";
|
||||||
|
|
||||||
/* Data Insights and Reporting Team */
|
/* Data Insights and Reporting Team */
|
||||||
public const string RiskInsightsCriticalApplication = "pm-14466-risk-insights-critical-application";
|
public const string RiskInsightsCriticalApplication = "pm-14466-risk-insights-critical-application";
|
||||||
|
@ -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.Constants;
|
||||||
using Bit.Core.Billing.Enums;
|
using Bit.Core.Billing.Enums;
|
||||||
using Bit.Core.Billing.Models;
|
|
||||||
using Bit.Core.Billing.Pricing;
|
using Bit.Core.Billing.Pricing;
|
||||||
using Bit.Core.Billing.Tax.Commands;
|
using Bit.Core.Billing.Tax.Commands;
|
||||||
using Bit.Core.Billing.Tax.Services;
|
using Bit.Core.Billing.Tax.Services;
|
||||||
@ -8,7 +7,6 @@ using Bit.Core.Services;
|
|||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using NSubstitute.ExceptionExtensions;
|
|
||||||
using Stripe;
|
using Stripe;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using static Bit.Core.Billing.Tax.Commands.OrganizationTrialParameters;
|
using static Bit.Core.Billing.Tax.Commands.OrganizationTrialParameters;
|
||||||
@ -273,74 +271,6 @@ public class PreviewTaxAmountCommandTests
|
|||||||
// Assert
|
// Assert
|
||||||
Assert.True(result.IsT1);
|
Assert.True(result.IsT1);
|
||||||
var badRequest = result.AsT1;
|
var badRequest = result.AsT1;
|
||||||
Assert.Equal(BillingErrorTranslationKeys.UnknownTaxIdType, 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);
|
||||||
}
|
|
||||||
|
|
||||||
[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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user