mirror of
https://github.com/bitwarden/server.git
synced 2025-07-02 00:22:50 -05:00
[AC-1938] Update provider payment method (#4140)
* Refactored GET provider subscription Refactoring this endpoint and its associated tests in preparation for the addition of more endpoints that share similar patterns * Replaced StripePaymentService call in AccountsController, OrganizationsController This was made in error during a previous PR. Since this is not related to Consolidated Billing, we want to try not to include it in these changes. * Removing GetPaymentInformation call from ProviderBillingService This method is a good call for the SubscriberService as we'll want to extend the functionality to all subscriber types * Refactored GetTaxInformation to use Billing owned DTO * Add UpdateTaxInformation to SubscriberService * Added GetTaxInformation and UpdateTaxInformation endpoints to ProviderBillingController * Added controller to manage creation of Stripe SetupIntents With the deprecation of the Sources API, we need to move the bank account creation process to using SetupIntents. This controller brings both the creation of "card" and "us_bank_account" SetupIntents under billing management. * Added UpdatePaymentMethod method to SubscriberService This method utilizes the SetupIntents created by the StripeController from the previous commit when a customer adds a card or us_bank_account payment method (Stripe). We need to cache the most recent SetupIntent for the subscriber so that we know which PaymentMethod is their most recent even when it hasn't been confirmed yet. * Refactored GetPaymentMethod to use billing owned DTO and check setup intents * Added GetPaymentMethod and UpdatePaymentMethod endpoints to ProviderBillingController * Re-added GetPaymentInformation endpoint to consolidate API calls on the payment method page * Added VerifyBankAccount endpoint to ProviderBillingController in order to finalize bank account payment methods * Updated BitPayInvoiceRequestModel to support providers * run dotnet format * Conner's feedback * Run dotnet format'
This commit is contained in:
@ -835,7 +835,7 @@ public class AccountsController : Controller
|
||||
throw new UnauthorizedAccessException();
|
||||
}
|
||||
|
||||
var taxInfo = await _subscriberService.GetTaxInformationAsync(user);
|
||||
var taxInfo = await _paymentService.GetTaxInfoAsync(user);
|
||||
return new TaxInfoResponseModel(taxInfo);
|
||||
}
|
||||
|
||||
|
@ -304,7 +304,7 @@ public class OrganizationsController(
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var taxInfo = await subscriberService.GetTaxInformationAsync(organization);
|
||||
var taxInfo = await paymentService.GetTaxInfoAsync(organization);
|
||||
return new TaxInfoResponseModel(taxInfo);
|
||||
}
|
||||
|
||||
|
@ -1,10 +1,17 @@
|
||||
using Bit.Api.Billing.Models.Responses;
|
||||
using Bit.Api.Billing.Models.Requests;
|
||||
using Bit.Api.Billing.Models.Responses;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Api.Billing.Controllers;
|
||||
|
||||
@ -13,59 +20,194 @@ namespace Bit.Api.Billing.Controllers;
|
||||
public class ProviderBillingController(
|
||||
ICurrentContext currentContext,
|
||||
IFeatureService featureService,
|
||||
IProviderBillingService providerBillingService) : Controller
|
||||
IProviderBillingService providerBillingService,
|
||||
IProviderRepository providerRepository,
|
||||
IStripeAdapter stripeAdapter,
|
||||
ISubscriberService subscriberService) : Controller
|
||||
{
|
||||
[HttpGet("subscription")]
|
||||
public async Task<IResult> GetSubscriptionAsync([FromRoute] Guid providerId)
|
||||
{
|
||||
if (!featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling))
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
if (!currentContext.ProviderProviderAdmin(providerId))
|
||||
{
|
||||
return TypedResults.Unauthorized();
|
||||
}
|
||||
|
||||
var providerSubscriptionDTO = await providerBillingService.GetSubscriptionDTO(providerId);
|
||||
|
||||
if (providerSubscriptionDTO == null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
var (providerPlans, subscription) = providerSubscriptionDTO;
|
||||
|
||||
var providerSubscriptionResponse = ProviderSubscriptionResponse.From(providerPlans, subscription);
|
||||
|
||||
return TypedResults.Ok(providerSubscriptionResponse);
|
||||
}
|
||||
|
||||
[HttpGet("payment-information")]
|
||||
public async Task<IResult> GetPaymentInformationAsync([FromRoute] Guid providerId)
|
||||
{
|
||||
if (!featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling))
|
||||
var (provider, result) = await GetAuthorizedBillableProviderOrResultAsync(providerId);
|
||||
|
||||
if (provider == null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var paymentInformation = await subscriberService.GetPaymentInformation(provider);
|
||||
|
||||
if (paymentInformation == null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
var response = PaymentInformationResponse.From(paymentInformation);
|
||||
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
|
||||
[HttpGet("payment-method")]
|
||||
public async Task<IResult> GetPaymentMethodAsync([FromRoute] Guid providerId)
|
||||
{
|
||||
var (provider, result) = await GetAuthorizedBillableProviderOrResultAsync(providerId);
|
||||
|
||||
if (provider == null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var maskedPaymentMethod = await subscriberService.GetPaymentMethod(provider);
|
||||
|
||||
if (maskedPaymentMethod == null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
var response = MaskedPaymentMethodResponse.From(maskedPaymentMethod);
|
||||
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
|
||||
[HttpPut("payment-method")]
|
||||
public async Task<IResult> UpdatePaymentMethodAsync(
|
||||
[FromRoute] Guid providerId,
|
||||
[FromBody] TokenizedPaymentMethodRequestBody requestBody)
|
||||
{
|
||||
var (provider, result) = await GetAuthorizedBillableProviderOrResultAsync(providerId);
|
||||
|
||||
if (provider == null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var tokenizedPaymentMethod = new TokenizedPaymentMethodDTO(
|
||||
requestBody.Type,
|
||||
requestBody.Token);
|
||||
|
||||
await subscriberService.UpdatePaymentMethod(provider, tokenizedPaymentMethod);
|
||||
|
||||
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId,
|
||||
new SubscriptionUpdateOptions
|
||||
{
|
||||
CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically
|
||||
});
|
||||
|
||||
return TypedResults.Ok();
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Route("payment-method/verify-bank-account")]
|
||||
public async Task<IResult> VerifyBankAccountAsync(
|
||||
[FromRoute] Guid providerId,
|
||||
[FromBody] VerifyBankAccountRequestBody requestBody)
|
||||
{
|
||||
var (provider, result) = await GetAuthorizedBillableProviderOrResultAsync(providerId);
|
||||
|
||||
if (provider == null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
await subscriberService.VerifyBankAccount(provider, (requestBody.Amount1, requestBody.Amount2));
|
||||
|
||||
return TypedResults.Ok();
|
||||
}
|
||||
|
||||
[HttpGet("subscription")]
|
||||
public async Task<IResult> GetSubscriptionAsync([FromRoute] Guid providerId)
|
||||
{
|
||||
var (provider, result) = await GetAuthorizedBillableProviderOrResultAsync(providerId);
|
||||
|
||||
if (provider == null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var consolidatedBillingSubscription = await providerBillingService.GetConsolidatedBillingSubscription(provider);
|
||||
|
||||
if (consolidatedBillingSubscription == null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
var response = ConsolidatedBillingSubscriptionResponse.From(consolidatedBillingSubscription);
|
||||
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
|
||||
[HttpGet("tax-information")]
|
||||
public async Task<IResult> GetTaxInformationAsync([FromRoute] Guid providerId)
|
||||
{
|
||||
var (provider, result) = await GetAuthorizedBillableProviderOrResultAsync(providerId);
|
||||
|
||||
if (provider == null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var taxInformation = await subscriberService.GetTaxInformation(provider);
|
||||
|
||||
if (taxInformation == null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
var response = TaxInformationResponse.From(taxInformation);
|
||||
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
|
||||
[HttpPut("tax-information")]
|
||||
public async Task<IResult> UpdateTaxInformationAsync(
|
||||
[FromRoute] Guid providerId,
|
||||
[FromBody] TaxInformationRequestBody requestBody)
|
||||
{
|
||||
var (provider, result) = await GetAuthorizedBillableProviderOrResultAsync(providerId);
|
||||
|
||||
if (provider == null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var taxInformation = new TaxInformationDTO(
|
||||
requestBody.Country,
|
||||
requestBody.PostalCode,
|
||||
requestBody.TaxId,
|
||||
requestBody.Line1,
|
||||
requestBody.Line2,
|
||||
requestBody.City,
|
||||
requestBody.State);
|
||||
|
||||
await subscriberService.UpdateTaxInformation(provider, taxInformation);
|
||||
|
||||
return TypedResults.Ok();
|
||||
}
|
||||
|
||||
private async Task<(Provider, IResult)> GetAuthorizedBillableProviderOrResultAsync(Guid providerId)
|
||||
{
|
||||
if (!featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling))
|
||||
{
|
||||
return (null, TypedResults.NotFound());
|
||||
}
|
||||
|
||||
var provider = await providerRepository.GetByIdAsync(providerId);
|
||||
|
||||
if (provider == null)
|
||||
{
|
||||
return (null, TypedResults.NotFound());
|
||||
}
|
||||
|
||||
if (!currentContext.ProviderProviderAdmin(providerId))
|
||||
{
|
||||
return TypedResults.Unauthorized();
|
||||
return (null, TypedResults.Unauthorized());
|
||||
}
|
||||
|
||||
var providerPaymentInformationDto = await providerBillingService.GetPaymentInformationAsync(providerId);
|
||||
|
||||
if (providerPaymentInformationDto == null)
|
||||
if (!provider.IsBillable())
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
return (null, TypedResults.Unauthorized());
|
||||
}
|
||||
|
||||
var (paymentSource, taxInfo) = providerPaymentInformationDto;
|
||||
|
||||
var providerPaymentInformationResponse = PaymentInformationResponse.From(paymentSource, taxInfo);
|
||||
|
||||
return TypedResults.Ok(providerPaymentInformationResponse);
|
||||
return (provider, null);
|
||||
}
|
||||
}
|
||||
|
49
src/Api/Billing/Controllers/StripeController.cs
Normal file
49
src/Api/Billing/Controllers/StripeController.cs
Normal file
@ -0,0 +1,49 @@
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Api.Billing.Controllers;
|
||||
|
||||
[Authorize("Application")]
|
||||
public class StripeController(
|
||||
IStripeAdapter stripeAdapter) : Controller
|
||||
{
|
||||
[HttpPost]
|
||||
[Route("~/setup-intent/bank-account")]
|
||||
public async Task<Ok<string>> CreateSetupIntentForBankAccountAsync()
|
||||
{
|
||||
var options = new SetupIntentCreateOptions
|
||||
{
|
||||
PaymentMethodOptions = new SetupIntentPaymentMethodOptionsOptions
|
||||
{
|
||||
UsBankAccount = new SetupIntentPaymentMethodOptionsUsBankAccountOptions
|
||||
{
|
||||
VerificationMethod = "microdeposits"
|
||||
}
|
||||
},
|
||||
PaymentMethodTypes = ["us_bank_account"],
|
||||
Usage = "off_session"
|
||||
};
|
||||
|
||||
var setupIntent = await stripeAdapter.SetupIntentCreate(options);
|
||||
|
||||
return TypedResults.Ok(setupIntent.ClientSecret);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Route("~/setup-intent/card")]
|
||||
public async Task<Ok<string>> CreateSetupIntentForCardAsync()
|
||||
{
|
||||
var options = new SetupIntentCreateOptions
|
||||
{
|
||||
PaymentMethodTypes = ["card"],
|
||||
Usage = "off_session"
|
||||
};
|
||||
|
||||
var setupIntent = await stripeAdapter.SetupIntentCreate(options);
|
||||
|
||||
return TypedResults.Ok(setupIntent.ClientSecret);
|
||||
}
|
||||
}
|
16
src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs
Normal file
16
src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs
Normal file
@ -0,0 +1,16 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Requests;
|
||||
|
||||
public class TaxInformationRequestBody
|
||||
{
|
||||
[Required]
|
||||
public string Country { get; set; }
|
||||
[Required]
|
||||
public string PostalCode { get; set; }
|
||||
public string TaxId { get; set; }
|
||||
public string Line1 { get; set; }
|
||||
public string Line2 { get; set; }
|
||||
public string City { get; set; }
|
||||
public string State { get; set; }
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Api.Utilities;
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Requests;
|
||||
|
||||
public class TokenizedPaymentMethodRequestBody
|
||||
{
|
||||
[Required]
|
||||
[EnumMatches<PaymentMethodType>(
|
||||
PaymentMethodType.BankAccount,
|
||||
PaymentMethodType.Card,
|
||||
PaymentMethodType.PayPal,
|
||||
ErrorMessage = "'type' must be BankAccount, Card or PayPal")]
|
||||
public PaymentMethodType Type { get; set; }
|
||||
[Required]
|
||||
public string Token { get; set; }
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Requests;
|
||||
|
||||
public class VerifyBankAccountRequestBody
|
||||
{
|
||||
[Range(0, 99)]
|
||||
public long Amount1 { get; set; }
|
||||
[Range(0, 99)]
|
||||
public long Amount2 { get; set; }
|
||||
}
|
@ -1,29 +1,29 @@
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Utilities;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Responses;
|
||||
|
||||
public record ProviderSubscriptionResponse(
|
||||
public record ConsolidatedBillingSubscriptionResponse(
|
||||
string Status,
|
||||
DateTime CurrentPeriodEndDate,
|
||||
decimal? DiscountPercentage,
|
||||
IEnumerable<ProviderPlanDTO> Plans)
|
||||
IEnumerable<ProviderPlanResponse> Plans)
|
||||
{
|
||||
private const string _annualCadence = "Annual";
|
||||
private const string _monthlyCadence = "Monthly";
|
||||
|
||||
public static ProviderSubscriptionResponse From(
|
||||
IEnumerable<ConfiguredProviderPlanDTO> providerPlans,
|
||||
Subscription subscription)
|
||||
public static ConsolidatedBillingSubscriptionResponse From(
|
||||
ConsolidatedBillingSubscriptionDTO consolidatedBillingSubscription)
|
||||
{
|
||||
var (providerPlans, subscription) = consolidatedBillingSubscription;
|
||||
|
||||
var providerPlansDTO = providerPlans
|
||||
.Select(providerPlan =>
|
||||
{
|
||||
var plan = StaticStore.GetPlan(providerPlan.PlanType);
|
||||
var cost = (providerPlan.SeatMinimum + providerPlan.PurchasedSeats) * plan.PasswordManager.SeatPrice;
|
||||
var cadence = plan.IsAnnual ? _annualCadence : _monthlyCadence;
|
||||
return new ProviderPlanDTO(
|
||||
return new ProviderPlanResponse(
|
||||
plan.Name,
|
||||
providerPlan.SeatMinimum,
|
||||
providerPlan.PurchasedSeats,
|
||||
@ -32,7 +32,7 @@ public record ProviderSubscriptionResponse(
|
||||
cadence);
|
||||
});
|
||||
|
||||
return new ProviderSubscriptionResponse(
|
||||
return new ConsolidatedBillingSubscriptionResponse(
|
||||
subscription.Status,
|
||||
subscription.CurrentPeriodEnd,
|
||||
subscription.Customer?.Discount?.Coupon?.PercentOff,
|
||||
@ -40,7 +40,7 @@ public record ProviderSubscriptionResponse(
|
||||
}
|
||||
}
|
||||
|
||||
public record ProviderPlanDTO(
|
||||
public record ProviderPlanResponse(
|
||||
string PlanName,
|
||||
int SeatMinimum,
|
||||
int PurchasedSeats,
|
@ -0,0 +1,16 @@
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Responses;
|
||||
|
||||
public record MaskedPaymentMethodResponse(
|
||||
PaymentMethodType Type,
|
||||
string Description,
|
||||
bool NeedsVerification)
|
||||
{
|
||||
public static MaskedPaymentMethodResponse From(MaskedPaymentMethodDTO maskedPaymentMethod)
|
||||
=> new(
|
||||
maskedPaymentMethod.Type,
|
||||
maskedPaymentMethod.Description,
|
||||
maskedPaymentMethod.NeedsVerification);
|
||||
}
|
@ -1,37 +1,15 @@
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Billing.Models;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Responses;
|
||||
|
||||
public record PaymentInformationResponse(PaymentMethod PaymentMethod, TaxInformation TaxInformation)
|
||||
public record PaymentInformationResponse(
|
||||
long AccountCredit,
|
||||
MaskedPaymentMethodDTO PaymentMethod,
|
||||
TaxInformationDTO TaxInformation)
|
||||
{
|
||||
public static PaymentInformationResponse From(BillingInfo.BillingSource billingSource, TaxInfo taxInfo)
|
||||
{
|
||||
var paymentMethodDto = new PaymentMethod(
|
||||
billingSource.Type, billingSource.Description, billingSource.CardBrand
|
||||
);
|
||||
|
||||
var taxInformationDto = new TaxInformation(
|
||||
taxInfo.BillingAddressCountry, taxInfo.BillingAddressPostalCode, taxInfo.TaxIdNumber,
|
||||
taxInfo.BillingAddressLine1, taxInfo.BillingAddressLine2, taxInfo.BillingAddressCity,
|
||||
taxInfo.BillingAddressState
|
||||
);
|
||||
|
||||
return new PaymentInformationResponse(paymentMethodDto, taxInformationDto);
|
||||
}
|
||||
|
||||
public static PaymentInformationResponse From(PaymentInformationDTO paymentInformation) =>
|
||||
new(
|
||||
paymentInformation.AccountCredit,
|
||||
paymentInformation.PaymentMethod,
|
||||
paymentInformation.TaxInformation);
|
||||
}
|
||||
|
||||
public record PaymentMethod(
|
||||
PaymentMethodType Type,
|
||||
string Description,
|
||||
string CardBrand);
|
||||
|
||||
public record TaxInformation(
|
||||
string Country,
|
||||
string PostalCode,
|
||||
string TaxId,
|
||||
string Line1,
|
||||
string Line2,
|
||||
string City,
|
||||
string State);
|
||||
|
23
src/Api/Billing/Models/Responses/TaxInformationResponse.cs
Normal file
23
src/Api/Billing/Models/Responses/TaxInformationResponse.cs
Normal file
@ -0,0 +1,23 @@
|
||||
using Bit.Core.Billing.Models;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Responses;
|
||||
|
||||
public record TaxInformationResponse(
|
||||
string Country,
|
||||
string PostalCode,
|
||||
string TaxId,
|
||||
string Line1,
|
||||
string Line2,
|
||||
string City,
|
||||
string State)
|
||||
{
|
||||
public static TaxInformationResponse From(TaxInformationDTO taxInformation)
|
||||
=> new(
|
||||
taxInformation.Country,
|
||||
taxInformation.PostalCode,
|
||||
taxInformation.TaxId,
|
||||
taxInformation.Line1,
|
||||
taxInformation.Line2,
|
||||
taxInformation.City,
|
||||
taxInformation.State);
|
||||
}
|
@ -7,6 +7,7 @@ public class BitPayInvoiceRequestModel : IValidatableObject
|
||||
{
|
||||
public Guid? UserId { get; set; }
|
||||
public Guid? OrganizationId { get; set; }
|
||||
public Guid? ProviderId { get; set; }
|
||||
public bool Credit { get; set; }
|
||||
[Required]
|
||||
public decimal? Amount { get; set; }
|
||||
@ -40,6 +41,10 @@ public class BitPayInvoiceRequestModel : IValidatableObject
|
||||
{
|
||||
posData = "organizationId:" + OrganizationId.Value;
|
||||
}
|
||||
else if (ProviderId.HasValue)
|
||||
{
|
||||
posData = "providerId:" + ProviderId.Value;
|
||||
}
|
||||
|
||||
if (Credit)
|
||||
{
|
||||
@ -57,9 +62,9 @@ public class BitPayInvoiceRequestModel : IValidatableObject
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
if (!UserId.HasValue && !OrganizationId.HasValue)
|
||||
if (!UserId.HasValue && !OrganizationId.HasValue && !ProviderId.HasValue)
|
||||
{
|
||||
yield return new ValidationResult("User or Organization is required.");
|
||||
yield return new ValidationResult("User, Organization or Provider is required.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user