1
0
mirror of https://github.com/bitwarden/server synced 2025-12-21 02:33:30 +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

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