mirror of
https://github.com/bitwarden/server
synced 2025-12-27 21:53:24 +00:00
[PM-21881] Manage payment details outside of checkout (#6032)
* Add feature flag * Further establish billing command pattern and use in PreviewTaxAmountCommand * Add billing address models/commands/queries/tests * Update TypeReadingJsonConverter to account for new union types * Add payment method models/commands/queries/tests * Add credit models/commands/queries/tests * Add command/query registrations * Add new endpoints to support new command model and payment functionality * Run dotnet format * Add InjectUserAttribute for easier AccountBillilngVNextController handling * Add InjectOrganizationAttribute for easier OrganizationBillingVNextController handling * Add InjectProviderAttribute for easier ProviderBillingVNextController handling * Add XML documentation for billing command pipeline * Fix StripeConstants post-nullability * More nullability cleanup * Run dotnet format
This commit is contained in:
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.Mvc;
|
||||
|
||||
@@ -6,20 +8,50 @@ namespace Bit.Api.Billing.Controllers;
|
||||
|
||||
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
|
||||
{
|
||||
public static BadRequest<ErrorResponseModel> BadRequest(Dictionary<string, IEnumerable<string>> errors) =>
|
||||
TypedResults.BadRequest(new ErrorResponseModel(errors));
|
||||
|
||||
public static BadRequest<ErrorResponseModel> BadRequest(string 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() =>
|
||||
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(
|
||||
new ErrorResponseModel(message),
|
||||
exception == null ? new ErrorResponseModel(message) : new ErrorResponseModel(message)
|
||||
{
|
||||
ExceptionMessage = exception.Message,
|
||||
ExceptionStackTrace = exception.StackTrace
|
||||
},
|
||||
statusCode: StatusCodes.Status500InternalServerError);
|
||||
|
||||
public static JsonHttpResult<ErrorResponseModel> Unauthorized(string message = "Unauthorized.") =>
|
||||
|
||||
@@ -28,9 +28,6 @@ public class TaxController(
|
||||
|
||||
var result = await previewTaxAmountCommand.Run(parameters);
|
||||
|
||||
return result.Match<IResult>(
|
||||
taxAmount => TypedResults.Ok(new { TaxAmount = taxAmount }),
|
||||
badRequest => Error.BadRequest(badRequest.TranslationKey),
|
||||
unhandled => Error.ServerError(unhandled.TranslationKey));
|
||||
return Handle(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
@@ -36,6 +38,13 @@ public static class StripeConstants
|
||||
public const string PaymentMethodMicroDepositVerificationDescriptorCodeMismatch = "payment_method_microdeposit_verification_descriptor_code_mismatch";
|
||||
public const string PaymentMethodMicroDepositVerificationTimeout = "payment_method_microdeposit_verification_timeout";
|
||||
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
|
||||
@@ -51,6 +60,7 @@ public static class StripeConstants
|
||||
public const string InvoiceApproved = "invoice_approved";
|
||||
public const string OrganizationId = "organizationId";
|
||||
public const string ProviderId = "providerId";
|
||||
public const string RetiredBraintreeCustomerId = "btCustomerId_old";
|
||||
public const string UserId = "userId";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Bit.Core.Billing.Caches;
|
||||
using Bit.Core.Billing.Caches.Implementations;
|
||||
using Bit.Core.Billing.Licenses.Extensions;
|
||||
using Bit.Core.Billing.Payment;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Billing.Services.Implementations;
|
||||
@@ -27,5 +28,6 @@ public static class ServiceCollectionExtensions
|
||||
services.AddLicenseServices();
|
||||
services.AddPricingClient();
|
||||
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;
|
||||
|
||||
@@ -23,4 +27,14 @@ public static class SubscriberExtensions
|
||||
? subscriberName
|
||||
: 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
|
||||
|
||||
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();
|
||||
|
||||
@@ -14,7 +14,9 @@ public abstract class TypeReadingJsonConverter<T> : JsonConverter<T>
|
||||
{
|
||||
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;
|
||||
}
|
||||
@@ -25,4 +27,10 @@ public abstract class TypeReadingJsonConverter<T> : JsonConverter<T>
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected T? Skip(ref Utf8JsonReader reader)
|
||||
{
|
||||
reader.Skip();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#nullable enable
|
||||
using Bit.Core.Billing.Commands;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Tax.Services;
|
||||
using Bit.Core.Services;
|
||||
@@ -20,111 +20,95 @@ public class PreviewTaxAmountCommand(
|
||||
ILogger<PreviewTaxAmountCommand> logger,
|
||||
IPricingClient pricingClient,
|
||||
IStripeAdapter stripeAdapter,
|
||||
ITaxService taxService) : IPreviewTaxAmountCommand
|
||||
ITaxService taxService) : BillingCommand<PreviewTaxAmountCommand>(logger), IPreviewTaxAmountCommand
|
||||
{
|
||||
public async Task<BillingCommandResult<decimal>> Run(OrganizationTrialParameters parameters)
|
||||
{
|
||||
var (planType, productType, taxInformation) = parameters;
|
||||
|
||||
var plan = await pricingClient.GetPlanOrThrow(planType);
|
||||
|
||||
var options = new InvoiceCreatePreviewOptions
|
||||
public Task<BillingCommandResult<decimal>> Run(OrganizationTrialParameters parameters)
|
||||
=> HandleAsync<decimal>(async () =>
|
||||
{
|
||||
Currency = "usd",
|
||||
CustomerDetails = new InvoiceCustomerDetailsOptions
|
||||
var (planType, productType, taxInformation) = parameters;
|
||||
|
||||
var plan = await pricingClient.GetPlanOrThrow(planType);
|
||||
|
||||
var options = new InvoiceCreatePreviewOptions
|
||||
{
|
||||
Address = new AddressOptions
|
||||
Currency = "usd",
|
||||
CustomerDetails = new InvoiceCustomerDetailsOptions
|
||||
{
|
||||
Country = taxInformation.Country,
|
||||
PostalCode = taxInformation.PostalCode
|
||||
}
|
||||
},
|
||||
SubscriptionDetails = new InvoiceSubscriptionDetailsOptions
|
||||
{
|
||||
Items = [
|
||||
new InvoiceSubscriptionDetailsItemOptions
|
||||
Address = new AddressOptions
|
||||
{
|
||||
Price = plan.HasNonSeatBasedPasswordManagerPlan() ? plan.PasswordManager.StripePlanId : plan.PasswordManager.StripeSeatPlanId,
|
||||
Quantity = 1
|
||||
Country = taxInformation.Country,
|
||||
PostalCode = taxInformation.PostalCode
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
},
|
||||
SubscriptionDetails = new InvoiceSubscriptionDetailsOptions
|
||||
{
|
||||
Type = taxIdType,
|
||||
Value = taxInformation.TaxId
|
||||
Items =
|
||||
[
|
||||
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);
|
||||
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
|
||||
|
||||
@@ -155,6 +155,7 @@ public static class FeatureFlagKeys
|
||||
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 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 */
|
||||
public const string RiskInsightsCriticalApplication = "pm-14466-risk-insights-critical-application";
|
||||
|
||||
Reference in New Issue
Block a user