1
0
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:
Alex Morask
2025-07-10 08:32:25 -05:00
committed by GitHub
parent 3bfc24523e
commit 7f65a655d4
52 changed files with 3736 additions and 215 deletions

View File

@@ -0,0 +1,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();
}
}

View 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();
}
}

View 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();
}
}

View File

@@ -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.") =>

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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,
};
}

View File

@@ -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!;
}

View File

@@ -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!;
}
}

View File

@@ -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, };
}

View File

@@ -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);
}
}

View File

@@ -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; }
}

View File

@@ -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()
};
}

View 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);
}
}

View 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);
}
}
}

View 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);
}

View File

@@ -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";
}

View File

@@ -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();
}
}

View File

@@ -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))
};
}

View File

@@ -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";
}

View 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);
}

View File

@@ -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))
};
}

View 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 }
});
}
}
}
}

View 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 });
}
}
}

View File

@@ -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);
});
}

View 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 };
}

View 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));
}

View File

@@ -0,0 +1,7 @@
namespace Bit.Core.Billing.Payment.Models;
public enum ProductUsageType
{
Personal,
Business
}

View File

@@ -0,0 +1,8 @@
namespace Bit.Core.Billing.Payment.Models;
public enum TokenizablePaymentMethodType
{
BankAccount,
Card,
PayPal
}

View File

@@ -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; }
}

View 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);
}
}

View 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;
}
}

View 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);
}
}

View 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>();
}
}

View File

@@ -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;
}
}

View File

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

View File

@@ -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";