mirror of
https://github.com/bitwarden/server.git
synced 2025-06-30 23:52:50 -05:00
[AC-2888] Improve consolidated billing error handling (#4548)
* Fix error handling in provider setup process This update ensures that when 'enable-consolidated-billing' is on, any exception thrown during the Stripe customer or subscription setup process for the provider will block the remainder of the setup process so the provider does not enter an invalid state * Refactor the way BillingException is thrown Made it simpler to just use the exception constructor and also ensured it was added to the exception handling middleware so it could provide a simple response to the client * Handle all Stripe exceptions in exception handling middleware * Fixed error response output for billing's provider controllers * Cleaned up billing owned provider controllers Changes were made based on feature updates by product and stuff that's no longer needed. No need to expose sensitive endpoints when they're not being used. * Reafctored get invoices Removed unnecssarily bloated method from SubscriberService * Updated error handling for generating the client invoice report * Moved get provider subscription to controller This is only used once and the service layer doesn't seem like the correct choice anymore when thinking about error handling with retrieval * Handled bad request for update tax information * Split out Stripe configuration from unauthorization * Run dotnet format * Addison's feedback
This commit is contained in:
@ -1,9 +1,7 @@
|
||||
using Bit.Api.AdminConsole.Models.Request.Providers;
|
||||
using Bit.Api.AdminConsole.Models.Response.Providers;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Business;
|
||||
@ -23,23 +21,15 @@ public class ProvidersController : Controller
|
||||
private readonly IProviderService _providerService;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly ILogger<ProvidersController> _logger;
|
||||
private readonly IProviderBillingService _providerBillingService;
|
||||
|
||||
public ProvidersController(IUserService userService, IProviderRepository providerRepository,
|
||||
IProviderService providerService, ICurrentContext currentContext, GlobalSettings globalSettings,
|
||||
IFeatureService featureService, ILogger<ProvidersController> logger,
|
||||
IProviderBillingService providerBillingService)
|
||||
IProviderService providerService, ICurrentContext currentContext, GlobalSettings globalSettings)
|
||||
{
|
||||
_userService = userService;
|
||||
_providerRepository = providerRepository;
|
||||
_providerService = providerService;
|
||||
_currentContext = currentContext;
|
||||
_globalSettings = globalSettings;
|
||||
_featureService = featureService;
|
||||
_logger = logger;
|
||||
_providerBillingService = providerBillingService;
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
@ -94,12 +84,8 @@ public class ProvidersController : Controller
|
||||
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
|
||||
var response =
|
||||
await _providerService.CompleteSetupAsync(model.ToProvider(provider), userId, model.Token, model.Key);
|
||||
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling))
|
||||
{
|
||||
var taxInfo = new TaxInfo
|
||||
var taxInfo = model.TaxInfo != null
|
||||
? new TaxInfo
|
||||
{
|
||||
BillingAddressCountry = model.TaxInfo.Country,
|
||||
BillingAddressPostalCode = model.TaxInfo.PostalCode,
|
||||
@ -108,20 +94,12 @@ public class ProvidersController : Controller
|
||||
BillingAddressLine2 = model.TaxInfo.Line2,
|
||||
BillingAddressCity = model.TaxInfo.City,
|
||||
BillingAddressState = model.TaxInfo.State
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await _providerBillingService.CreateCustomer(provider, taxInfo);
|
||||
|
||||
await _providerBillingService.StartSubscription(provider);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// We don't want to trap the user on the setup page, so we'll let this go through but the provider will be in an un-billable state.
|
||||
_logger.LogError("Failed to create subscription for provider with ID {ID} during setup", provider.Id);
|
||||
}
|
||||
}
|
||||
: null;
|
||||
|
||||
var response =
|
||||
await _providerService.CompleteSetupAsync(model.ToProvider(provider), userId, model.Token, model.Key,
|
||||
taxInfo);
|
||||
|
||||
return new ProviderResponseModel(response);
|
||||
}
|
||||
|
@ -3,7 +3,9 @@ using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Models.Api;
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Api.Billing.Controllers;
|
||||
@ -11,8 +13,25 @@ namespace Bit.Api.Billing.Controllers;
|
||||
public abstract class BaseProviderController(
|
||||
ICurrentContext currentContext,
|
||||
IFeatureService featureService,
|
||||
IProviderRepository providerRepository) : Controller
|
||||
ILogger<BaseProviderController> logger,
|
||||
IProviderRepository providerRepository,
|
||||
IUserService userService) : Controller
|
||||
{
|
||||
protected readonly IUserService UserService = userService;
|
||||
|
||||
protected static NotFound<ErrorResponseModel> NotFoundResponse() =>
|
||||
TypedResults.NotFound(new ErrorResponseModel("Resource not found."));
|
||||
|
||||
protected static JsonHttpResult<ErrorResponseModel> ServerErrorResponse(string errorMessage) =>
|
||||
TypedResults.Json(
|
||||
new ErrorResponseModel(errorMessage),
|
||||
statusCode: StatusCodes.Status500InternalServerError);
|
||||
|
||||
protected static JsonHttpResult<ErrorResponseModel> UnauthorizedResponse() =>
|
||||
TypedResults.Json(
|
||||
new ErrorResponseModel("Unauthorized."),
|
||||
statusCode: StatusCodes.Status401Unauthorized);
|
||||
|
||||
protected Task<(Provider, IResult)> TryGetBillableProviderForAdminOperation(
|
||||
Guid providerId) => TryGetBillableProviderAsync(providerId, currentContext.ProviderProviderAdmin);
|
||||
|
||||
@ -25,26 +44,53 @@ public abstract class BaseProviderController(
|
||||
{
|
||||
if (!featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling))
|
||||
{
|
||||
return (null, TypedResults.NotFound());
|
||||
logger.LogError(
|
||||
"Cannot run Consolidated Billing operation for provider ({ProviderID}) while feature flag is disabled",
|
||||
providerId);
|
||||
|
||||
return (null, NotFoundResponse());
|
||||
}
|
||||
|
||||
var provider = await providerRepository.GetByIdAsync(providerId);
|
||||
|
||||
if (provider == null)
|
||||
{
|
||||
return (null, TypedResults.NotFound());
|
||||
logger.LogError(
|
||||
"Cannot find provider ({ProviderID}) for Consolidated Billing operation",
|
||||
providerId);
|
||||
|
||||
return (null, NotFoundResponse());
|
||||
}
|
||||
|
||||
if (!checkAuthorization(providerId))
|
||||
{
|
||||
return (null, TypedResults.Unauthorized());
|
||||
var user = await UserService.GetUserByPrincipalAsync(User);
|
||||
|
||||
logger.LogError(
|
||||
"User ({UserID}) is not authorized to perform Consolidated Billing operation for provider ({ProviderID})",
|
||||
user?.Id, providerId);
|
||||
|
||||
return (null, UnauthorizedResponse());
|
||||
}
|
||||
|
||||
if (!provider.IsBillable())
|
||||
{
|
||||
return (null, TypedResults.Unauthorized());
|
||||
logger.LogError(
|
||||
"Cannot run Consolidated Billing operation for provider ({ProviderID}) that is not billable",
|
||||
providerId);
|
||||
|
||||
return (null, UnauthorizedResponse());
|
||||
}
|
||||
|
||||
return (provider, null);
|
||||
if (provider.IsStripeEnabled())
|
||||
{
|
||||
return (provider, null);
|
||||
}
|
||||
|
||||
logger.LogError(
|
||||
"Cannot run Consolidated Billing operation for provider ({ProviderID}) that is missing Stripe configuration",
|
||||
providerId);
|
||||
|
||||
return (null, ServerErrorResponse("Something went wrong with your request. Please contact support."));
|
||||
}
|
||||
}
|
||||
|
@ -1,15 +1,19 @@
|
||||
using Bit.Api.Billing.Models.Requests;
|
||||
using Bit.Api.Billing.Models.Responses;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Repositories;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Models.Api;
|
||||
using Bit.Core.Models.BitStripe;
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Stripe;
|
||||
|
||||
using static Bit.Core.Billing.Utilities;
|
||||
|
||||
namespace Bit.Api.Billing.Controllers;
|
||||
|
||||
[Route("providers/{providerId:guid}/billing")]
|
||||
@ -17,10 +21,13 @@ namespace Bit.Api.Billing.Controllers;
|
||||
public class ProviderBillingController(
|
||||
ICurrentContext currentContext,
|
||||
IFeatureService featureService,
|
||||
ILogger<BaseProviderController> logger,
|
||||
IProviderBillingService providerBillingService,
|
||||
IProviderPlanRepository providerPlanRepository,
|
||||
IProviderRepository providerRepository,
|
||||
ISubscriberService subscriberService,
|
||||
IStripeAdapter stripeAdapter,
|
||||
ISubscriberService subscriberService) : BaseProviderController(currentContext, featureService, providerRepository)
|
||||
IUserService userService) : BaseProviderController(currentContext, featureService, logger, providerRepository, userService)
|
||||
{
|
||||
[HttpGet("invoices")]
|
||||
public async Task<IResult> GetInvoicesAsync([FromRoute] Guid providerId)
|
||||
@ -32,7 +39,10 @@ public class ProviderBillingController(
|
||||
return result;
|
||||
}
|
||||
|
||||
var invoices = await subscriberService.GetInvoices(provider);
|
||||
var invoices = await stripeAdapter.InvoiceListAsync(new StripeInvoiceListOptions
|
||||
{
|
||||
Customer = provider.GatewayCustomerId
|
||||
});
|
||||
|
||||
var response = InvoicesResponse.From(invoices);
|
||||
|
||||
@ -53,7 +63,7 @@ public class ProviderBillingController(
|
||||
|
||||
if (reportContent == null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
return ServerErrorResponse("We had a problem generating your invoice CSV. Please contact support.");
|
||||
}
|
||||
|
||||
return TypedResults.File(
|
||||
@ -61,95 +71,6 @@ public class ProviderBillingController(
|
||||
"text/csv");
|
||||
}
|
||||
|
||||
[HttpGet("payment-information")]
|
||||
public async Task<IResult> GetPaymentInformationAsync([FromRoute] Guid providerId)
|
||||
{
|
||||
var (provider, result) = await TryGetBillableProviderForAdminOperation(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 TryGetBillableProviderForAdminOperation(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 TryGetBillableProviderForAdminOperation(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 TryGetBillableProviderForAdminOperation(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)
|
||||
{
|
||||
@ -160,36 +81,20 @@ public class ProviderBillingController(
|
||||
return result;
|
||||
}
|
||||
|
||||
var consolidatedBillingSubscription = await providerBillingService.GetConsolidatedBillingSubscription(provider);
|
||||
var subscription = await stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId,
|
||||
new SubscriptionGetOptions { Expand = ["customer.tax_ids", "test_clock"] });
|
||||
|
||||
if (consolidatedBillingSubscription == null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
|
||||
|
||||
var response = ConsolidatedBillingSubscriptionResponse.From(consolidatedBillingSubscription);
|
||||
var taxInformation = GetTaxInformation(subscription.Customer);
|
||||
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
var subscriptionSuspension = await GetSubscriptionSuspensionAsync(stripeAdapter, subscription);
|
||||
|
||||
[HttpGet("tax-information")]
|
||||
public async Task<IResult> GetTaxInformationAsync([FromRoute] Guid providerId)
|
||||
{
|
||||
var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId);
|
||||
|
||||
if (provider == null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var taxInformation = await subscriberService.GetTaxInformation(provider);
|
||||
|
||||
if (taxInformation == null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
var response = TaxInformationResponse.From(taxInformation);
|
||||
var response = ProviderSubscriptionResponse.From(
|
||||
subscription,
|
||||
providerPlans,
|
||||
taxInformation,
|
||||
subscriptionSuspension);
|
||||
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
@ -206,7 +111,13 @@ public class ProviderBillingController(
|
||||
return result;
|
||||
}
|
||||
|
||||
var taxInformation = new TaxInformationDTO(
|
||||
if (requestBody is not { Country: not null, PostalCode: not null })
|
||||
{
|
||||
return TypedResults.BadRequest(
|
||||
new ErrorResponseModel("Country and postal code are required to update your tax information."));
|
||||
}
|
||||
|
||||
var taxInformation = new TaxInformation(
|
||||
requestBody.Country,
|
||||
requestBody.PostalCode,
|
||||
requestBody.TaxId,
|
||||
|
@ -15,13 +15,13 @@ namespace Bit.Api.Billing.Controllers;
|
||||
public class ProviderClientsController(
|
||||
ICurrentContext currentContext,
|
||||
IFeatureService featureService,
|
||||
ILogger<ProviderClientsController> logger,
|
||||
ILogger<BaseProviderController> logger,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IProviderBillingService providerBillingService,
|
||||
IProviderOrganizationRepository providerOrganizationRepository,
|
||||
IProviderRepository providerRepository,
|
||||
IProviderService providerService,
|
||||
IUserService userService) : BaseProviderController(currentContext, featureService, providerRepository)
|
||||
IUserService userService) : BaseProviderController(currentContext, featureService, logger, providerRepository, userService)
|
||||
{
|
||||
[HttpPost]
|
||||
public async Task<IResult> CreateAsync(
|
||||
@ -35,11 +35,11 @@ public class ProviderClientsController(
|
||||
return result;
|
||||
}
|
||||
|
||||
var user = await userService.GetUserByPrincipalAsync(User);
|
||||
var user = await UserService.GetUserByPrincipalAsync(User);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
return TypedResults.Unauthorized();
|
||||
return UnauthorizedResponse();
|
||||
}
|
||||
|
||||
var organizationSignup = new OrganizationSignup
|
||||
@ -63,13 +63,6 @@ public class ProviderClientsController(
|
||||
|
||||
var clientOrganization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
|
||||
|
||||
if (clientOrganization == null)
|
||||
{
|
||||
logger.LogError("Newly created client organization ({ID}) could not be found", providerOrganization.OrganizationId);
|
||||
|
||||
return TypedResults.Problem();
|
||||
}
|
||||
|
||||
await providerBillingService.ScaleSeats(
|
||||
provider,
|
||||
requestBody.PlanType,
|
||||
@ -103,18 +96,11 @@ public class ProviderClientsController(
|
||||
|
||||
if (providerOrganization == null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
return NotFoundResponse();
|
||||
}
|
||||
|
||||
var clientOrganization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
|
||||
|
||||
if (clientOrganization == null)
|
||||
{
|
||||
logger.LogError("The client organization ({OrganizationID}) represented by provider organization ({ProviderOrganizationID}) could not be found.", providerOrganization.OrganizationId, providerOrganization.Id);
|
||||
|
||||
return TypedResults.Problem();
|
||||
}
|
||||
|
||||
if (clientOrganization.Seats != requestBody.AssignedSeats)
|
||||
{
|
||||
await providerBillingService.AssignSeatsToClientOrganization(
|
||||
|
@ -3,16 +3,16 @@
|
||||
namespace Bit.Api.Billing.Models.Responses;
|
||||
|
||||
public record InvoicesResponse(
|
||||
List<InvoiceDTO> Invoices)
|
||||
List<InvoiceResponse> Invoices)
|
||||
{
|
||||
public static InvoicesResponse From(IEnumerable<Invoice> invoices) => new(
|
||||
invoices
|
||||
.Where(i => i.Status is "open" or "paid" or "uncollectible")
|
||||
.OrderByDescending(i => i.Created)
|
||||
.Select(InvoiceDTO.From).ToList());
|
||||
.Select(InvoiceResponse.From).ToList());
|
||||
}
|
||||
|
||||
public record InvoiceDTO(
|
||||
public record InvoiceResponse(
|
||||
string Id,
|
||||
DateTime Date,
|
||||
string Number,
|
||||
@ -21,7 +21,7 @@ public record InvoiceDTO(
|
||||
DateTime? DueDate,
|
||||
string Url)
|
||||
{
|
||||
public static InvoiceDTO From(Invoice invoice) => new(
|
||||
public static InvoiceResponse From(Invoice invoice) => new(
|
||||
invoice.Id,
|
||||
invoice.Created,
|
||||
invoice.Number,
|
||||
|
@ -5,7 +5,7 @@ namespace Bit.Api.Billing.Models.Responses;
|
||||
public record PaymentInformationResponse(
|
||||
long AccountCredit,
|
||||
MaskedPaymentMethodDTO PaymentMethod,
|
||||
TaxInformationDTO TaxInformation)
|
||||
TaxInformation TaxInformation)
|
||||
{
|
||||
public static PaymentInformationResponse From(PaymentInformationDTO paymentInformation) =>
|
||||
new(
|
||||
|
@ -1,43 +1,48 @@
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Entities;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Utilities;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Responses;
|
||||
|
||||
public record ConsolidatedBillingSubscriptionResponse(
|
||||
public record ProviderSubscriptionResponse(
|
||||
string Status,
|
||||
DateTime CurrentPeriodEndDate,
|
||||
decimal? DiscountPercentage,
|
||||
string CollectionMethod,
|
||||
IEnumerable<ProviderPlanResponse> Plans,
|
||||
long AccountCredit,
|
||||
TaxInformationDTO TaxInformation,
|
||||
TaxInformation TaxInformation,
|
||||
DateTime? CancelAt,
|
||||
SubscriptionSuspensionDTO Suspension)
|
||||
SubscriptionSuspension Suspension)
|
||||
{
|
||||
private const string _annualCadence = "Annual";
|
||||
private const string _monthlyCadence = "Monthly";
|
||||
|
||||
public static ConsolidatedBillingSubscriptionResponse From(
|
||||
ConsolidatedBillingSubscriptionDTO consolidatedBillingSubscription)
|
||||
public static ProviderSubscriptionResponse From(
|
||||
Subscription subscription,
|
||||
ICollection<ProviderPlan> providerPlans,
|
||||
TaxInformation taxInformation,
|
||||
SubscriptionSuspension subscriptionSuspension)
|
||||
{
|
||||
var (providerPlans, subscription, taxInformation, suspension) = consolidatedBillingSubscription;
|
||||
|
||||
var providerPlanResponses = providerPlans
|
||||
.Select(providerPlan =>
|
||||
.Where(providerPlan => providerPlan.IsConfigured())
|
||||
.Select(ConfiguredProviderPlan.From)
|
||||
.Select(configuredProviderPlan =>
|
||||
{
|
||||
var plan = StaticStore.GetPlan(providerPlan.PlanType);
|
||||
var cost = (providerPlan.SeatMinimum + providerPlan.PurchasedSeats) * plan.PasswordManager.ProviderPortalSeatPrice;
|
||||
var plan = StaticStore.GetPlan(configuredProviderPlan.PlanType);
|
||||
var cost = (configuredProviderPlan.SeatMinimum + configuredProviderPlan.PurchasedSeats) * plan.PasswordManager.ProviderPortalSeatPrice;
|
||||
var cadence = plan.IsAnnual ? _annualCadence : _monthlyCadence;
|
||||
return new ProviderPlanResponse(
|
||||
plan.Name,
|
||||
providerPlan.SeatMinimum,
|
||||
providerPlan.PurchasedSeats,
|
||||
providerPlan.AssignedSeats,
|
||||
configuredProviderPlan.SeatMinimum,
|
||||
configuredProviderPlan.PurchasedSeats,
|
||||
configuredProviderPlan.AssignedSeats,
|
||||
cost,
|
||||
cadence);
|
||||
});
|
||||
|
||||
return new ConsolidatedBillingSubscriptionResponse(
|
||||
return new ProviderSubscriptionResponse(
|
||||
subscription.Status,
|
||||
subscription.CurrentPeriodEnd,
|
||||
subscription.Customer?.Discount?.Coupon?.PercentOff,
|
||||
@ -46,7 +51,7 @@ public record ConsolidatedBillingSubscriptionResponse(
|
||||
subscription.Customer?.Balance ?? 0,
|
||||
taxInformation,
|
||||
subscription.CancelAt,
|
||||
suspension);
|
||||
subscriptionSuspension);
|
||||
}
|
||||
}
|
||||
|
@ -11,7 +11,7 @@ public record TaxInformationResponse(
|
||||
string City,
|
||||
string State)
|
||||
{
|
||||
public static TaxInformationResponse From(TaxInformationDTO taxInformation)
|
||||
public static TaxInformationResponse From(TaxInformation taxInformation)
|
||||
=> new(
|
||||
taxInformation.Country,
|
||||
taxInformation.PostalCode,
|
||||
|
@ -1,4 +1,6 @@
|
||||
using Bit.Api.Models.Public.Response;
|
||||
using System.Text;
|
||||
using Bit.Api.Models.Public.Response;
|
||||
using Bit.Core.Billing;
|
||||
using Bit.Core.Exceptions;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
@ -49,18 +51,18 @@ public class ExceptionHandlerFilterAttribute : ExceptionFilterAttribute
|
||||
errorMessage = badRequestException.Message;
|
||||
}
|
||||
}
|
||||
else if (exception is StripeException stripeException && stripeException?.StripeError?.Type == "card_error")
|
||||
else if (exception is StripeException { StripeError.Type: "card_error" } stripeCardErrorException)
|
||||
{
|
||||
context.HttpContext.Response.StatusCode = 400;
|
||||
if (_publicApi)
|
||||
{
|
||||
publicErrorModel = new ErrorResponseModel(stripeException.StripeError.Param,
|
||||
stripeException.Message);
|
||||
publicErrorModel = new ErrorResponseModel(stripeCardErrorException.StripeError.Param,
|
||||
stripeCardErrorException.Message);
|
||||
}
|
||||
else
|
||||
{
|
||||
internalErrorModel = new InternalApi.ErrorResponseModel(stripeException.StripeError.Param,
|
||||
stripeException.Message);
|
||||
internalErrorModel = new InternalApi.ErrorResponseModel(stripeCardErrorException.StripeError.Param,
|
||||
stripeCardErrorException.Message);
|
||||
}
|
||||
}
|
||||
else if (exception is GatewayException)
|
||||
@ -68,6 +70,40 @@ public class ExceptionHandlerFilterAttribute : ExceptionFilterAttribute
|
||||
errorMessage = exception.Message;
|
||||
context.HttpContext.Response.StatusCode = 400;
|
||||
}
|
||||
else if (exception is BillingException billingException)
|
||||
{
|
||||
errorMessage = billingException.Response;
|
||||
context.HttpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
||||
}
|
||||
else if (exception is StripeException stripeException)
|
||||
{
|
||||
var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<ExceptionHandlerFilterAttribute>>();
|
||||
|
||||
var error = stripeException.Message;
|
||||
|
||||
if (stripeException.StripeError != null)
|
||||
{
|
||||
var stringBuilder = new StringBuilder();
|
||||
|
||||
if (!string.IsNullOrEmpty(stripeException.StripeError.Code))
|
||||
{
|
||||
stringBuilder.Append($"{stripeException.StripeError.Code} | ");
|
||||
}
|
||||
|
||||
stringBuilder.Append(stripeException.StripeError.Message);
|
||||
|
||||
if (!string.IsNullOrEmpty(stripeException.StripeError.DocUrl))
|
||||
{
|
||||
stringBuilder.Append($" > {stripeException.StripeError.DocUrl}");
|
||||
}
|
||||
|
||||
error = stringBuilder.ToString();
|
||||
}
|
||||
|
||||
logger.LogError("An unhandled error occurred while communicating with Stripe: {Error}", error);
|
||||
errorMessage = "Something went wrong with your request. Please contact support.";
|
||||
context.HttpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
||||
}
|
||||
else if (exception is NotSupportedException && !string.IsNullOrWhiteSpace(exception.Message))
|
||||
{
|
||||
errorMessage = exception.Message;
|
||||
|
@ -7,7 +7,7 @@ namespace Bit.Core.AdminConsole.Services;
|
||||
|
||||
public interface IProviderService
|
||||
{
|
||||
Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key);
|
||||
Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo = null);
|
||||
Task UpdateAsync(Provider provider, bool updateBilling = false);
|
||||
|
||||
Task<List<ProviderUser>> InviteUserAsync(ProviderUserInvite<string> invite);
|
||||
|
@ -7,7 +7,7 @@ namespace Bit.Core.AdminConsole.Services.NoopImplementations;
|
||||
|
||||
public class NoopProviderService : IProviderService
|
||||
{
|
||||
public Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key) => throw new NotImplementedException();
|
||||
public Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo = null) => throw new NotImplementedException();
|
||||
|
||||
public Task UpdateAsync(Provider provider, bool updateBilling = false) => throw new NotImplementedException();
|
||||
|
||||
|
@ -1,9 +1,9 @@
|
||||
namespace Bit.Core.Billing;
|
||||
|
||||
public class BillingException(
|
||||
string clientFriendlyMessage,
|
||||
string internalMessage = null,
|
||||
Exception innerException = null) : Exception(internalMessage, innerException)
|
||||
string response = null,
|
||||
string message = null,
|
||||
Exception innerException = null) : Exception(message, innerException)
|
||||
{
|
||||
public string ClientFriendlyMessage { get; set; } = clientFriendlyMessage;
|
||||
public string Response { get; } = response ?? "Something went wrong with your request. Please contact support.";
|
||||
}
|
||||
|
@ -21,6 +21,11 @@ public static class StripeConstants
|
||||
public const string SecretsManagerStandalone = "sm-standalone";
|
||||
}
|
||||
|
||||
public static class ErrorCodes
|
||||
{
|
||||
public const string CustomerTaxLocationInvalid = "customer_tax_location_invalid";
|
||||
}
|
||||
|
||||
public static class PaymentMethodTypes
|
||||
{
|
||||
public const string Card = "card";
|
||||
|
@ -2,6 +2,7 @@
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Stripe;
|
||||
|
||||
@ -24,9 +25,9 @@ public static class BillingExtensions
|
||||
PlanType: PlanType.TeamsMonthly or PlanType.EnterpriseMonthly
|
||||
};
|
||||
|
||||
public static bool IsStripeEnabled(this Organization organization)
|
||||
=> !string.IsNullOrEmpty(organization.GatewayCustomerId) &&
|
||||
!string.IsNullOrEmpty(organization.GatewaySubscriptionId);
|
||||
public static bool IsStripeEnabled(this ISubscriber subscriber)
|
||||
=> !string.IsNullOrEmpty(subscriber.GatewayCustomerId) &&
|
||||
!string.IsNullOrEmpty(subscriber.GatewaySubscriptionId);
|
||||
|
||||
public static bool IsUnverifiedBankAccount(this SetupIntent setupIntent) =>
|
||||
setupIntent is
|
||||
|
@ -3,7 +3,7 @@ using Bit.Core.Billing.Enums;
|
||||
|
||||
namespace Bit.Core.Billing.Models;
|
||||
|
||||
public record ConfiguredProviderPlanDTO(
|
||||
public record ConfiguredProviderPlan(
|
||||
Guid Id,
|
||||
Guid ProviderId,
|
||||
PlanType PlanType,
|
||||
@ -11,9 +11,9 @@ public record ConfiguredProviderPlanDTO(
|
||||
int PurchasedSeats,
|
||||
int AssignedSeats)
|
||||
{
|
||||
public static ConfiguredProviderPlanDTO From(ProviderPlan providerPlan) =>
|
||||
public static ConfiguredProviderPlan From(ProviderPlan providerPlan) =>
|
||||
providerPlan.IsConfigured()
|
||||
? new ConfiguredProviderPlanDTO(
|
||||
? new ConfiguredProviderPlan(
|
||||
providerPlan.Id,
|
||||
providerPlan.ProviderId,
|
||||
providerPlan.PlanType,
|
@ -1,9 +0,0 @@
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Models;
|
||||
|
||||
public record ConsolidatedBillingSubscriptionDTO(
|
||||
List<ConfiguredProviderPlanDTO> ProviderPlans,
|
||||
Subscription Subscription,
|
||||
TaxInformationDTO TaxInformation,
|
||||
SubscriptionSuspensionDTO Suspension);
|
@ -3,4 +3,4 @@
|
||||
public record PaymentInformationDTO(
|
||||
long AccountCredit,
|
||||
MaskedPaymentMethodDTO PaymentMethod,
|
||||
TaxInformationDTO TaxInformation);
|
||||
TaxInformation TaxInformation);
|
||||
|
@ -1,6 +1,6 @@
|
||||
namespace Bit.Core.Billing.Models;
|
||||
|
||||
public record SubscriptionSuspensionDTO(
|
||||
public record SubscriptionSuspension(
|
||||
DateTime SuspensionDate,
|
||||
DateTime UnpaidPeriodEndDate,
|
||||
int GracePeriod);
|
@ -1,6 +1,6 @@
|
||||
namespace Bit.Core.Billing.Models;
|
||||
|
||||
public record TaxInformationDTO(
|
||||
public record TaxInformation(
|
||||
string Country,
|
||||
string PostalCode,
|
||||
string TaxId,
|
@ -3,8 +3,8 @@ using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.Billing.Entities;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Models.Business;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Services;
|
||||
|
||||
@ -24,16 +24,6 @@ public interface IProviderBillingService
|
||||
Organization organization,
|
||||
int seats);
|
||||
|
||||
/// <summary>
|
||||
/// Create a Stripe <see cref="Stripe.Customer"/> for the specified <paramref name="provider"/> utilizing the provided <paramref name="taxInfo"/>.
|
||||
/// </summary>
|
||||
/// <param name="provider">The <see cref="Provider"/> to create a Stripe customer for.</param>
|
||||
/// <param name="taxInfo">The <see cref="TaxInfo"/> to use for calculating the customer's automatic tax.</param>
|
||||
/// <returns></returns>
|
||||
Task CreateCustomer(
|
||||
Provider provider,
|
||||
TaxInfo taxInfo);
|
||||
|
||||
/// <summary>
|
||||
/// Create a Stripe <see cref="Stripe.Customer"/> for the provided client <paramref name="organization"/> utilizing
|
||||
/// the address and tax information of its <paramref name="provider"/>.
|
||||
@ -65,15 +55,6 @@ public interface IProviderBillingService
|
||||
Guid providerId,
|
||||
PlanType planType);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the <paramref name="provider"/>'s consolidated billing subscription, which includes their Stripe subscription and configured provider plans.
|
||||
/// </summary>
|
||||
/// <param name="provider">The provider to retrieve the consolidated billing subscription for.</param>
|
||||
/// <returns>A <see cref="ConsolidatedBillingSubscriptionDTO"/> containing the provider's Stripe <see cref="Stripe.Subscription"/> and a list of <see cref="ConfiguredProviderPlanDTO"/>s representing their configured plans.</returns>
|
||||
/// <remarks>This method opts for returning <see langword="null"/> rather than throwing exceptions, making it ideal for surfacing data from API endpoints.</remarks>
|
||||
Task<ConsolidatedBillingSubscriptionDTO> GetConsolidatedBillingSubscription(
|
||||
Provider provider);
|
||||
|
||||
/// <summary>
|
||||
/// Scales the <paramref name="provider"/>'s seats for the specified <paramref name="planType"/> using the provided <paramref name="seatAdjustment"/>.
|
||||
/// This operation may autoscale the provider's Stripe <see cref="Stripe.Subscription"/> depending on the <paramref name="provider"/>'s seat minimum for the
|
||||
@ -88,11 +69,23 @@ public interface IProviderBillingService
|
||||
int seatAdjustment);
|
||||
|
||||
/// <summary>
|
||||
/// Starts a Stripe <see cref="Stripe.Subscription"/> for the given <paramref name="provider"/> given it has an existing Stripe <see cref="Stripe.Customer"/>.
|
||||
/// For use during the provider setup process, this method creates a Stripe <see cref="Stripe.Customer"/> for the specified <paramref name="provider"/> utilizing the provided <paramref name="taxInfo"/>.
|
||||
/// </summary>
|
||||
/// <param name="provider">The <see cref="Provider"/> to create a Stripe customer for.</param>
|
||||
/// <param name="taxInfo">The <see cref="TaxInfo"/> to use for calculating the customer's automatic tax.</param>
|
||||
/// <returns>The newly created <see cref="Stripe.Customer"/> for the <paramref name="provider"/>.</returns>
|
||||
Task<Customer> SetupCustomer(
|
||||
Provider provider,
|
||||
TaxInfo taxInfo);
|
||||
|
||||
/// <summary>
|
||||
/// For use during the provider setup process, this method starts a Stripe <see cref="Stripe.Subscription"/> for the given <paramref name="provider"/>.
|
||||
/// <see cref="Provider"/> subscriptions will always be started with a <see cref="Stripe.SubscriptionItem"/> for both the <see cref="PlanType.TeamsMonthly"/>
|
||||
/// and <see cref="PlanType.EnterpriseMonthly"/> plan, and the quantity for each item will be equal the provider's seat minimum for each respective plan.
|
||||
/// </summary>
|
||||
/// <param name="provider">The provider to create the <see cref="Stripe.Subscription"/> for.</param>
|
||||
Task StartSubscription(
|
||||
/// <returns>The newly created <see cref="Stripe.Subscription"/> for the <paramref name="provider"/>.</returns>
|
||||
/// <remarks>This method requires the <paramref name="provider"/> to already have a linked Stripe <see cref="Stripe.Customer"/> via its <see cref="Provider.GatewayCustomerId"/> field.</remarks>
|
||||
Task<Subscription> SetupSubscription(
|
||||
Provider provider);
|
||||
}
|
||||
|
@ -1,7 +1,6 @@
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.BitStripe;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Services;
|
||||
@ -47,18 +46,6 @@ public interface ISubscriberService
|
||||
ISubscriber subscriber,
|
||||
CustomerGetOptions customerGetOptions = null);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a list of Stripe <see cref="Invoice"/> objects using the <paramref name="subscriber"/>'s <see cref="ISubscriber.GatewayCustomerId"/> property.
|
||||
/// </summary>
|
||||
/// <param name="subscriber">The subscriber to retrieve the Stripe invoices for.</param>
|
||||
/// <param name="invoiceListOptions">Optional parameters that can be passed to Stripe to expand, modify or filter the invoices. The <see cref="subscriber"/>'s
|
||||
/// <see cref="ISubscriber.GatewayCustomerId"/> will be automatically attached to the provided options as the <see cref="InvoiceListOptions.Customer"/> parameter.</param>
|
||||
/// <returns>A list of Stripe <see cref="Invoice"/> objects.</returns>
|
||||
/// <remarks>This method opts for returning an empty list rather than throwing exceptions, making it ideal for surfacing data from API endpoints.</remarks>
|
||||
Task<List<Invoice>> GetInvoices(
|
||||
ISubscriber subscriber,
|
||||
StripeInvoiceListOptions invoiceListOptions = null);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the account credit, a masked representation of the default payment method and the tax information for the
|
||||
/// provided <paramref name="subscriber"/>. This is essentially a consolidated invocation of the <see cref="GetPaymentMethod"/>
|
||||
@ -106,10 +93,10 @@ public interface ISubscriberService
|
||||
/// Retrieves the <see cref="subscriber"/>'s tax information using their Stripe <see cref="Stripe.Customer"/>'s <see cref="Stripe.Customer.Address"/>.
|
||||
/// </summary>
|
||||
/// <param name="subscriber">The subscriber to retrieve the tax information for.</param>
|
||||
/// <returns>A <see cref="TaxInformationDTO"/> representing the <paramref name="subscriber"/>'s tax information.</returns>
|
||||
/// <returns>A <see cref="TaxInformation"/> representing the <paramref name="subscriber"/>'s tax information.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when the <paramref name="subscriber"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>This method opts for returning <see langword="null"/> rather than throwing exceptions, making it ideal for surfacing data from API endpoints.</remarks>
|
||||
Task<TaxInformationDTO> GetTaxInformation(
|
||||
Task<TaxInformation> GetTaxInformation(
|
||||
ISubscriber subscriber);
|
||||
|
||||
/// <summary>
|
||||
@ -137,10 +124,10 @@ public interface ISubscriberService
|
||||
/// Updates the tax information for the provided <paramref name="subscriber"/>.
|
||||
/// </summary>
|
||||
/// <param name="subscriber">The <paramref name="subscriber"/> to update the tax information for.</param>
|
||||
/// <param name="taxInformation">A <see cref="TaxInformationDTO"/> representing the <paramref name="subscriber"/>'s updated tax information.</param>
|
||||
/// <param name="taxInformation">A <see cref="TaxInformation"/> representing the <paramref name="subscriber"/>'s updated tax information.</param>
|
||||
Task UpdateTaxInformation(
|
||||
ISubscriber subscriber,
|
||||
TaxInformationDTO taxInformation);
|
||||
TaxInformation taxInformation);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the subscriber's pending bank account using the provided <paramref name="microdeposits"/>.
|
||||
|
@ -2,7 +2,6 @@
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.BitStripe;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
@ -37,7 +36,7 @@ public class SubscriberService(
|
||||
{
|
||||
logger.LogWarning("Cannot cancel subscription ({ID}) that's already inactive", subscription.Id);
|
||||
|
||||
throw ContactSupport();
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
var metadata = new Dictionary<string, string>
|
||||
@ -148,7 +147,7 @@ public class SubscriberService(
|
||||
{
|
||||
logger.LogError("Cannot retrieve customer for subscriber ({SubscriberID}) with no {FieldName}", subscriber.Id, nameof(subscriber.GatewayCustomerId));
|
||||
|
||||
throw ContactSupport();
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
try
|
||||
@ -163,48 +162,16 @@ public class SubscriberService(
|
||||
logger.LogError("Could not find Stripe customer ({CustomerID}) for subscriber ({SubscriberID})",
|
||||
subscriber.GatewayCustomerId, subscriber.Id);
|
||||
|
||||
throw ContactSupport();
|
||||
throw new BillingException();
|
||||
}
|
||||
catch (StripeException exception)
|
||||
catch (StripeException stripeException)
|
||||
{
|
||||
logger.LogError("An error occurred while trying to retrieve Stripe customer ({CustomerID}) for subscriber ({SubscriberID}): {Error}",
|
||||
subscriber.GatewayCustomerId, subscriber.Id, exception.Message);
|
||||
subscriber.GatewayCustomerId, subscriber.Id, stripeException.Message);
|
||||
|
||||
throw ContactSupport("An error occurred while trying to retrieve a Stripe Customer", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<Invoice>> GetInvoices(
|
||||
ISubscriber subscriber,
|
||||
StripeInvoiceListOptions invoiceListOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subscriber);
|
||||
|
||||
if (string.IsNullOrEmpty(subscriber.GatewayCustomerId))
|
||||
{
|
||||
logger.LogError("Cannot retrieve invoices for subscriber ({SubscriberID}) with no {FieldName}", subscriber.Id, nameof(subscriber.GatewayCustomerId));
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (invoiceListOptions == null)
|
||||
{
|
||||
invoiceListOptions = new StripeInvoiceListOptions { Customer = subscriber.GatewayCustomerId };
|
||||
}
|
||||
else
|
||||
{
|
||||
invoiceListOptions.Customer = subscriber.GatewayCustomerId;
|
||||
}
|
||||
|
||||
return await stripeAdapter.InvoiceListAsync(invoiceListOptions);
|
||||
}
|
||||
catch (StripeException exception)
|
||||
{
|
||||
logger.LogError("An error occurred while trying to retrieve Stripe invoices for subscriber ({SubscriberID}): {Error}", subscriber.Id, exception.Message);
|
||||
|
||||
return [];
|
||||
throw new BillingException(
|
||||
message: "An error occurred while trying to retrieve a Stripe customer",
|
||||
innerException: stripeException);
|
||||
}
|
||||
}
|
||||
|
||||
@ -294,7 +261,7 @@ public class SubscriberService(
|
||||
{
|
||||
logger.LogError("Cannot retrieve subscription for subscriber ({SubscriberID}) with no {FieldName}", subscriber.Id, nameof(subscriber.GatewaySubscriptionId));
|
||||
|
||||
throw ContactSupport();
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
try
|
||||
@ -309,18 +276,20 @@ public class SubscriberService(
|
||||
logger.LogError("Could not find Stripe subscription ({SubscriptionID}) for subscriber ({SubscriberID})",
|
||||
subscriber.GatewaySubscriptionId, subscriber.Id);
|
||||
|
||||
throw ContactSupport();
|
||||
throw new BillingException();
|
||||
}
|
||||
catch (StripeException exception)
|
||||
catch (StripeException stripeException)
|
||||
{
|
||||
logger.LogError("An error occurred while trying to retrieve Stripe subscription ({SubscriptionID}) for subscriber ({SubscriberID}): {Error}",
|
||||
subscriber.GatewaySubscriptionId, subscriber.Id, exception.Message);
|
||||
subscriber.GatewaySubscriptionId, subscriber.Id, stripeException.Message);
|
||||
|
||||
throw ContactSupport("An error occurred while trying to retrieve a Stripe Subscription", exception);
|
||||
throw new BillingException(
|
||||
message: "An error occurred while trying to retrieve a Stripe subscription",
|
||||
innerException: stripeException);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<TaxInformationDTO> GetTaxInformation(
|
||||
public async Task<TaxInformation> GetTaxInformation(
|
||||
ISubscriber subscriber)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subscriber);
|
||||
@ -337,7 +306,7 @@ public class SubscriberService(
|
||||
|
||||
if (string.IsNullOrEmpty(subscriber.GatewayCustomerId))
|
||||
{
|
||||
throw ContactSupport();
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
var stripeCustomer = await GetCustomerOrThrow(subscriber, new CustomerGetOptions
|
||||
@ -353,7 +322,7 @@ public class SubscriberService(
|
||||
{
|
||||
logger.LogError("Failed to retrieve Braintree customer ({ID}) when removing payment method", braintreeCustomerId);
|
||||
|
||||
throw ContactSupport();
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
if (braintreeCustomer.DefaultPaymentMethod != null)
|
||||
@ -369,7 +338,7 @@ public class SubscriberService(
|
||||
logger.LogError("Failed to update payment method for Braintree customer ({ID}) | Message: {Message}",
|
||||
braintreeCustomerId, updateCustomerResult.Message);
|
||||
|
||||
throw ContactSupport();
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
var deletePaymentMethodResult = await braintreeGateway.PaymentMethod.DeleteAsync(existingDefaultPaymentMethod.Token);
|
||||
@ -384,7 +353,7 @@ public class SubscriberService(
|
||||
"Failed to delete Braintree payment method for Customer ({ID}), re-linked payment method. Message: {Message}",
|
||||
braintreeCustomerId, deletePaymentMethodResult.Message);
|
||||
|
||||
throw ContactSupport();
|
||||
throw new BillingException();
|
||||
}
|
||||
}
|
||||
else
|
||||
@ -437,7 +406,7 @@ public class SubscriberService(
|
||||
{
|
||||
logger.LogError("Updated payment method for ({SubscriberID}) must contain a token", subscriber.Id);
|
||||
|
||||
throw ContactSupport();
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
// ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
|
||||
@ -462,7 +431,7 @@ public class SubscriberService(
|
||||
{
|
||||
logger.LogError("There were more than 1 setup intents for subscriber's ({SubscriberID}) updated payment method", subscriber.Id);
|
||||
|
||||
throw ContactSupport();
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
var matchingSetupIntent = setupIntentsForUpdatedPaymentMethod.First();
|
||||
@ -551,7 +520,7 @@ public class SubscriberService(
|
||||
{
|
||||
logger.LogError("Failed to retrieve Braintree customer ({BraintreeCustomerId}) when updating payment method for subscriber ({SubscriberID})", braintreeCustomerId, subscriber.Id);
|
||||
|
||||
throw ContactSupport();
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
await ReplaceBraintreePaymentMethodAsync(braintreeCustomer, token);
|
||||
@ -570,14 +539,14 @@ public class SubscriberService(
|
||||
{
|
||||
logger.LogError("Cannot update subscriber's ({SubscriberID}) payment method to type ({PaymentMethodType}) as it is not supported", subscriber.Id, type.ToString());
|
||||
|
||||
throw ContactSupport();
|
||||
throw new BillingException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpdateTaxInformation(
|
||||
ISubscriber subscriber,
|
||||
TaxInformationDTO taxInformation)
|
||||
TaxInformation taxInformation)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subscriber);
|
||||
ArgumentNullException.ThrowIfNull(taxInformation);
|
||||
@ -635,7 +604,7 @@ public class SubscriberService(
|
||||
{
|
||||
logger.LogError("No setup intent ID exists to verify for subscriber with ID ({SubscriberID})", subscriber.Id);
|
||||
|
||||
throw ContactSupport();
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
var (amount1, amount2) = microdeposits;
|
||||
@ -706,7 +675,7 @@ public class SubscriberService(
|
||||
|
||||
logger.LogError("Failed to create Braintree customer for subscriber ({ID})", subscriber.Id);
|
||||
|
||||
throw ContactSupport();
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
private async Task<MaskedPaymentMethodDTO> GetMaskedPaymentMethodDTOAsync(
|
||||
@ -751,7 +720,7 @@ public class SubscriberService(
|
||||
return MaskedPaymentMethodDTO.From(setupIntent);
|
||||
}
|
||||
|
||||
private static TaxInformationDTO GetTaxInformationDTOFrom(
|
||||
private static TaxInformation GetTaxInformationDTOFrom(
|
||||
Customer customer)
|
||||
{
|
||||
if (customer.Address == null)
|
||||
@ -759,7 +728,7 @@ public class SubscriberService(
|
||||
return null;
|
||||
}
|
||||
|
||||
return new TaxInformationDTO(
|
||||
return new TaxInformation(
|
||||
customer.Address.Country,
|
||||
customer.Address.PostalCode,
|
||||
customer.TaxIds?.FirstOrDefault()?.Value,
|
||||
@ -825,7 +794,7 @@ public class SubscriberService(
|
||||
{
|
||||
logger.LogError("Failed to replace payment method for Braintree customer ({ID}) - Creation of new payment method failed | Error: {Error}", customer.Id, createPaymentMethodResult.Message);
|
||||
|
||||
throw ContactSupport();
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
var updateCustomerResult = await braintreeGateway.Customer.UpdateAsync(
|
||||
@ -839,7 +808,7 @@ public class SubscriberService(
|
||||
|
||||
await braintreeGateway.PaymentMethod.DeleteAsync(createPaymentMethodResult.Target.Token);
|
||||
|
||||
throw ContactSupport();
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
if (existingDefaultPaymentMethod != null)
|
||||
|
@ -8,12 +8,7 @@ public static class Utilities
|
||||
{
|
||||
public const string BraintreeCustomerIdKey = "btCustomerId";
|
||||
|
||||
public static BillingException ContactSupport(
|
||||
string internalMessage = null,
|
||||
Exception innerException = null) => new("Something went wrong with your request. Please contact support.",
|
||||
internalMessage, innerException);
|
||||
|
||||
public static async Task<SubscriptionSuspensionDTO> GetSuspensionAsync(
|
||||
public static async Task<SubscriptionSuspension> GetSubscriptionSuspensionAsync(
|
||||
IStripeAdapter stripeAdapter,
|
||||
Subscription subscription)
|
||||
{
|
||||
@ -49,7 +44,7 @@ public static class Utilities
|
||||
|
||||
const int gracePeriod = 14;
|
||||
|
||||
return new SubscriptionSuspensionDTO(
|
||||
return new SubscriptionSuspension(
|
||||
firstOverdueInvoice.Created.AddDays(gracePeriod),
|
||||
firstOverdueInvoice.PeriodEnd,
|
||||
gracePeriod);
|
||||
@ -67,7 +62,7 @@ public static class Utilities
|
||||
|
||||
const int gracePeriod = 30;
|
||||
|
||||
return new SubscriptionSuspensionDTO(
|
||||
return new SubscriptionSuspension(
|
||||
firstOverdueInvoice.DueDate.Value.AddDays(gracePeriod),
|
||||
firstOverdueInvoice.PeriodEnd,
|
||||
gracePeriod);
|
||||
@ -75,4 +70,21 @@ public static class Utilities
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static TaxInformation GetTaxInformation(Customer customer)
|
||||
{
|
||||
if (customer.Address == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new TaxInformation(
|
||||
customer.Address.Country,
|
||||
customer.Address.PostalCode,
|
||||
customer.TaxIds?.FirstOrDefault()?.Value,
|
||||
customer.Address.Line1,
|
||||
customer.Address.Line2,
|
||||
customer.Address.City,
|
||||
customer.Address.State);
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,8 @@
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Stripe;
|
||||
|
||||
using static Bit.Core.Billing.Utilities;
|
||||
|
||||
namespace Bit.Core.Models.Business;
|
||||
|
||||
public class ProviderSubscriptionUpdate : SubscriptionUpdate
|
||||
@ -21,7 +20,8 @@ public class ProviderSubscriptionUpdate : SubscriptionUpdate
|
||||
{
|
||||
if (!planType.SupportsConsolidatedBilling())
|
||||
{
|
||||
throw ContactSupport($"Cannot create a {nameof(ProviderSubscriptionUpdate)} for {nameof(PlanType)} that doesn't support consolidated billing");
|
||||
throw new BillingException(
|
||||
message: $"Cannot create a {nameof(ProviderSubscriptionUpdate)} for {nameof(PlanType)} that doesn't support consolidated billing");
|
||||
}
|
||||
|
||||
var plan = Utilities.StaticStore.GetPlan(planType);
|
||||
|
Reference in New Issue
Block a user