1
0
mirror of https://github.com/bitwarden/server.git synced 2025-06-30 15:42:48 -05:00

[AC-2576] Replace Billing commands and queries with services (#4070)

* Replace SubscriberQueries with SubscriberService

* Replace OrganizationBillingQueries with OrganizationBillingService

* Replace ProviderBillingQueries with ProviderBillingService, move to Commercial

* Replace AssignSeatsToClientOrganizationCommand with ProviderBillingService, move to commercial

* Replace ScaleSeatsCommand with ProviderBillingService and move to Commercial

* Replace CancelSubscriptionCommand with SubscriberService

* Replace CreateCustomerCommand with ProviderBillingService and move to Commercial

* Replace StartSubscriptionCommand with ProviderBillingService and moved to Commercial

* Replaced RemovePaymentMethodCommand with SubscriberService

* Formatting

* Used dotnet format this time

* Changing ProviderBillingService to scoped

* Found circular dependency'

* One more time with feeling

* Formatting

* Fix error in remove org from provider

* Missed test fix in conflit

* [AC-1937] Server: Implement endpoint to retrieve provider payment information (#4107)

* Move the gettax and paymentmethod from stripepayment class

Signed-off-by: Cy Okeke <cokeke@bitwarden.com>

* Add the method to retrieve the tax and payment details

Signed-off-by: Cy Okeke <cokeke@bitwarden.com>

* Add unit tests for the paymentInformation method

Signed-off-by: Cy Okeke <cokeke@bitwarden.com>

* Add the endpoint to retrieve paymentinformation

Signed-off-by: Cy Okeke <cokeke@bitwarden.com>

* Add unit tests to the SubscriberService

Signed-off-by: Cy Okeke <cokeke@bitwarden.com>

* Remove the getTaxInfoAsync update reference

Signed-off-by: Cy Okeke <cokeke@bitwarden.com>

---------

Signed-off-by: Cy Okeke <cokeke@bitwarden.com>

---------

Signed-off-by: Cy Okeke <cokeke@bitwarden.com>
Co-authored-by: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com>
This commit is contained in:
Alex Morask
2024-05-23 10:17:00 -04:00
committed by GitHub
parent a9ab894893
commit 06910175e2
56 changed files with 3452 additions and 3426 deletions

View File

@ -7,8 +7,8 @@ using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@ -54,9 +54,8 @@ public class OrganizationsController : Controller
private readonly IServiceAccountRepository _serviceAccountRepository;
private readonly IProviderOrganizationRepository _providerOrganizationRepository;
private readonly IRemoveOrganizationFromProviderCommand _removeOrganizationFromProviderCommand;
private readonly IRemovePaymentMethodCommand _removePaymentMethodCommand;
private readonly IFeatureService _featureService;
private readonly IScaleSeatsCommand _scaleSeatsCommand;
private readonly IProviderBillingService _providerBillingService;
public OrganizationsController(
IOrganizationService organizationService,
@ -82,9 +81,8 @@ public class OrganizationsController : Controller
IServiceAccountRepository serviceAccountRepository,
IProviderOrganizationRepository providerOrganizationRepository,
IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand,
IRemovePaymentMethodCommand removePaymentMethodCommand,
IFeatureService featureService,
IScaleSeatsCommand scaleSeatsCommand)
IProviderBillingService providerBillingService)
{
_organizationService = organizationService;
_organizationRepository = organizationRepository;
@ -109,9 +107,8 @@ public class OrganizationsController : Controller
_serviceAccountRepository = serviceAccountRepository;
_providerOrganizationRepository = providerOrganizationRepository;
_removeOrganizationFromProviderCommand = removeOrganizationFromProviderCommand;
_removePaymentMethodCommand = removePaymentMethodCommand;
_featureService = featureService;
_scaleSeatsCommand = scaleSeatsCommand;
_providerBillingService = providerBillingService;
}
[RequirePermission(Permission.Org_List_View)]
@ -256,7 +253,7 @@ public class OrganizationsController : Controller
if (provider.IsBillable())
{
await _scaleSeatsCommand.ScalePasswordManagerSeats(
await _providerBillingService.ScaleSeats(
provider,
organization.PlanType,
-organization.Seats ?? 0);
@ -378,11 +375,6 @@ public class OrganizationsController : Controller
providerOrganization,
organization);
if (organization.IsStripeEnabled())
{
await _removePaymentMethodCommand.RemovePaymentMethod(organization);
}
return Json(null);
}
private async Task<Organization> GetOrganization(Guid id, OrganizationEditModel model)
@ -443,5 +435,4 @@ public class OrganizationsController : Controller
return organization;
}
}

View File

@ -2,8 +2,6 @@
using Bit.Admin.Utilities;
using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Extensions;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
@ -20,19 +18,16 @@ public class ProviderOrganizationsController : Controller
private readonly IProviderOrganizationRepository _providerOrganizationRepository;
private readonly IOrganizationRepository _organizationRepository;
private readonly IRemoveOrganizationFromProviderCommand _removeOrganizationFromProviderCommand;
private readonly IRemovePaymentMethodCommand _removePaymentMethodCommand;
public ProviderOrganizationsController(IProviderRepository providerRepository,
IProviderOrganizationRepository providerOrganizationRepository,
IOrganizationRepository organizationRepository,
IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand,
IRemovePaymentMethodCommand removePaymentMethodCommand)
IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand)
{
_providerRepository = providerRepository;
_providerOrganizationRepository = providerOrganizationRepository;
_organizationRepository = organizationRepository;
_removeOrganizationFromProviderCommand = removeOrganizationFromProviderCommand;
_removePaymentMethodCommand = removePaymentMethodCommand;
}
[HttpPost]
@ -69,12 +64,6 @@ public class ProviderOrganizationsController : Controller
return BadRequest(ex.Message);
}
if (organization.IsStripeEnabled())
{
await _removePaymentMethodCommand.RemovePaymentMethod(organization);
}
return Json(null);
}
}

View File

@ -19,8 +19,8 @@ using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.Services;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@ -55,7 +55,7 @@ public class OrganizationsController : Controller
private readonly IPushNotificationService _pushNotificationService;
private readonly IOrganizationEnableCollectionEnhancementsCommand _organizationEnableCollectionEnhancementsCommand;
private readonly IProviderRepository _providerRepository;
private readonly IScaleSeatsCommand _scaleSeatsCommand;
private readonly IProviderBillingService _providerBillingService;
private readonly IDataProtectorTokenFactory<OrgDeleteTokenable> _orgDeleteTokenDataFactory;
public OrganizationsController(
@ -76,7 +76,7 @@ public class OrganizationsController : Controller
IPushNotificationService pushNotificationService,
IOrganizationEnableCollectionEnhancementsCommand organizationEnableCollectionEnhancementsCommand,
IProviderRepository providerRepository,
IScaleSeatsCommand scaleSeatsCommand,
IProviderBillingService providerBillingService,
IDataProtectorTokenFactory<OrgDeleteTokenable> orgDeleteTokenDataFactory)
{
_organizationRepository = organizationRepository;
@ -96,7 +96,7 @@ public class OrganizationsController : Controller
_pushNotificationService = pushNotificationService;
_organizationEnableCollectionEnhancementsCommand = organizationEnableCollectionEnhancementsCommand;
_providerRepository = providerRepository;
_scaleSeatsCommand = scaleSeatsCommand;
_providerBillingService = providerBillingService;
_orgDeleteTokenDataFactory = orgDeleteTokenDataFactory;
}
@ -274,7 +274,7 @@ public class OrganizationsController : Controller
if (provider.IsBillable())
{
await _scaleSeatsCommand.ScalePasswordManagerSeats(
await _providerBillingService.ScaleSeats(
provider,
organization.PlanType,
-organization.Seats ?? 0);
@ -305,7 +305,7 @@ public class OrganizationsController : Controller
var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id);
if (provider.IsBillable())
{
await _scaleSeatsCommand.ScalePasswordManagerSeats(
await _providerBillingService.ScaleSeats(
provider,
organization.PlanType,
-organization.Seats ?? 0);

View File

@ -4,8 +4,6 @@ using Bit.Api.Models.Response;
using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Extensions;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
@ -26,7 +24,6 @@ public class ProviderOrganizationsController : Controller
private readonly IProviderRepository _providerRepository;
private readonly IProviderService _providerService;
private readonly IRemoveOrganizationFromProviderCommand _removeOrganizationFromProviderCommand;
private readonly IRemovePaymentMethodCommand _removePaymentMethodCommand;
private readonly IUserService _userService;
public ProviderOrganizationsController(
@ -36,7 +33,6 @@ public class ProviderOrganizationsController : Controller
IProviderRepository providerRepository,
IProviderService providerService,
IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand,
IRemovePaymentMethodCommand removePaymentMethodCommand,
IUserService userService)
{
_currentContext = currentContext;
@ -45,7 +41,6 @@ public class ProviderOrganizationsController : Controller
_providerRepository = providerRepository;
_providerService = providerService;
_removeOrganizationFromProviderCommand = removeOrganizationFromProviderCommand;
_removePaymentMethodCommand = removePaymentMethodCommand;
_userService = userService;
}
@ -112,10 +107,5 @@ public class ProviderOrganizationsController : Controller
provider,
providerOrganization,
organization);
if (organization.IsStripeEnabled())
{
await _removePaymentMethodCommand.RemovePaymentMethod(organization);
}
}
}

View File

@ -3,7 +3,7 @@ using Bit.Api.AdminConsole.Models.Response.Providers;
using Bit.Core;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
@ -24,13 +24,13 @@ public class ProvidersController : Controller
private readonly ICurrentContext _currentContext;
private readonly GlobalSettings _globalSettings;
private readonly IFeatureService _featureService;
private readonly IStartSubscriptionCommand _startSubscriptionCommand;
private readonly ILogger<ProvidersController> _logger;
private readonly IProviderBillingService _providerBillingService;
public ProvidersController(IUserService userService, IProviderRepository providerRepository,
IProviderService providerService, ICurrentContext currentContext, GlobalSettings globalSettings,
IFeatureService featureService, IStartSubscriptionCommand startSubscriptionCommand,
ILogger<ProvidersController> logger)
IFeatureService featureService, ILogger<ProvidersController> logger,
IProviderBillingService providerBillingService)
{
_userService = userService;
_providerRepository = providerRepository;
@ -38,8 +38,8 @@ public class ProvidersController : Controller
_currentContext = currentContext;
_globalSettings = globalSettings;
_featureService = featureService;
_startSubscriptionCommand = startSubscriptionCommand;
_logger = logger;
_providerBillingService = providerBillingService;
}
[HttpGet("{id:guid}")]
@ -112,7 +112,9 @@ public class ProvidersController : Controller
try
{
await _startSubscriptionCommand.StartSubscription(provider, taxInfo);
await _providerBillingService.CreateCustomer(provider, taxInfo);
await _providerBillingService.StartSubscription(provider);
}
catch
{

View File

@ -21,9 +21,8 @@ using Bit.Core.Auth.Services;
using Bit.Core.Auth.UserFeatures.UserKey;
using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
using Bit.Core.Auth.Utilities;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Queries;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
@ -67,8 +66,7 @@ public class AccountsController : Controller
private readonly ISetInitialMasterPasswordCommand _setInitialMasterPasswordCommand;
private readonly IRotateUserKeyCommand _rotateUserKeyCommand;
private readonly IFeatureService _featureService;
private readonly ICancelSubscriptionCommand _cancelSubscriptionCommand;
private readonly ISubscriberQueries _subscriberQueries;
private readonly ISubscriberService _subscriberService;
private readonly IReferenceEventService _referenceEventService;
private readonly ICurrentContext _currentContext;
@ -102,8 +100,7 @@ public class AccountsController : Controller
ISetInitialMasterPasswordCommand setInitialMasterPasswordCommand,
IRotateUserKeyCommand rotateUserKeyCommand,
IFeatureService featureService,
ICancelSubscriptionCommand cancelSubscriptionCommand,
ISubscriberQueries subscriberQueries,
ISubscriberService subscriberService,
IReferenceEventService referenceEventService,
ICurrentContext currentContext,
IRotationValidator<IEnumerable<CipherWithIdRequestModel>, IEnumerable<Cipher>> cipherValidator,
@ -131,8 +128,7 @@ public class AccountsController : Controller
_setInitialMasterPasswordCommand = setInitialMasterPasswordCommand;
_rotateUserKeyCommand = rotateUserKeyCommand;
_featureService = featureService;
_cancelSubscriptionCommand = cancelSubscriptionCommand;
_subscriberQueries = subscriberQueries;
_subscriberService = subscriberService;
_referenceEventService = referenceEventService;
_currentContext = currentContext;
_cipherValidator = cipherValidator;
@ -798,9 +794,7 @@ public class AccountsController : Controller
throw new UnauthorizedAccessException();
}
var subscription = await _subscriberQueries.GetSubscriptionOrThrow(user);
await _cancelSubscriptionCommand.CancelSubscription(subscription,
await _subscriberService.CancelSubscription(user,
new OffboardingSurveyResponse
{
UserId = user.Id,
@ -841,7 +835,7 @@ public class AccountsController : Controller
throw new UnauthorizedAccessException();
}
var taxInfo = await _paymentService.GetTaxInfoAsync(user);
var taxInfo = await _subscriberService.GetTaxInformationAsync(user);
return new TaxInfoResponseModel(taxInfo);
}

View File

@ -1,8 +1,7 @@
using Bit.Api.Billing.Models.Responses;
using Bit.Api.Models.Response;
using Bit.Core.Billing.Queries;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
@ -14,15 +13,15 @@ namespace Bit.Api.Billing.Controllers;
[Route("organizations/{organizationId:guid}/billing")]
[Authorize("Application")]
public class OrganizationBillingController(
IOrganizationBillingQueries organizationBillingQueries,
ICurrentContext currentContext,
IOrganizationBillingService organizationBillingService,
IOrganizationRepository organizationRepository,
IPaymentService paymentService) : Controller
{
[HttpGet("metadata")]
public async Task<IResult> GetMetadataAsync([FromRoute] Guid organizationId)
{
var metadata = await organizationBillingQueries.GetMetadata(organizationId);
var metadata = await organizationBillingService.GetMetadata(organizationId);
if (metadata == null)
{
@ -36,20 +35,24 @@ public class OrganizationBillingController(
[HttpGet]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<BillingResponseModel> GetBilling(Guid organizationId)
public async Task<IResult> GetBillingAsync(Guid organizationId)
{
if (!await currentContext.ViewBillingHistory(organizationId))
{
throw new NotFoundException();
return TypedResults.Unauthorized();
}
var organization = await organizationRepository.GetByIdAsync(organizationId);
if (organization == null)
{
throw new NotFoundException();
return TypedResults.NotFound();
}
var billingInfo = await paymentService.GetBillingAsync(organization);
return new BillingResponseModel(billingInfo);
var response = new BillingResponseModel(billingInfo);
return TypedResults.Ok(response);
}
}

View File

@ -5,10 +5,9 @@ using Bit.Api.Models.Request;
using Bit.Api.Models.Request.Organizations;
using Bit.Api.Models.Response;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Queries;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@ -42,9 +41,8 @@ public class OrganizationsController(
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
IUpgradeOrganizationPlanCommand upgradeOrganizationPlanCommand,
IAddSecretsManagerSubscriptionCommand addSecretsManagerSubscriptionCommand,
ICancelSubscriptionCommand cancelSubscriptionCommand,
ISubscriberQueries subscriberQueries,
IReferenceEventService referenceEventService)
IReferenceEventService referenceEventService,
ISubscriberService subscriberService)
: Controller
{
[HttpGet("{id}/billing-status")]
@ -261,9 +259,7 @@ public class OrganizationsController(
throw new NotFoundException();
}
var subscription = await subscriberQueries.GetSubscriptionOrThrow(organization);
await cancelSubscriptionCommand.CancelSubscription(subscription,
await subscriberService.CancelSubscription(organization,
new OffboardingSurveyResponse
{
UserId = currentContext.UserId!.Value,
@ -308,7 +304,7 @@ public class OrganizationsController(
throw new NotFoundException();
}
var taxInfo = await paymentService.GetTaxInfoAsync(organization);
var taxInfo = await subscriberService.GetTaxInformationAsync(organization);
return new TaxInfoResponseModel(taxInfo);
}

View File

@ -1,6 +1,6 @@
using Bit.Api.Billing.Models.Responses;
using Bit.Core;
using Bit.Core.Billing.Queries;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization;
@ -13,7 +13,7 @@ namespace Bit.Api.Billing.Controllers;
public class ProviderBillingController(
ICurrentContext currentContext,
IFeatureService featureService,
IProviderBillingQueries providerBillingQueries) : Controller
IProviderBillingService providerBillingService) : Controller
{
[HttpGet("subscription")]
public async Task<IResult> GetSubscriptionAsync([FromRoute] Guid providerId)
@ -28,7 +28,7 @@ public class ProviderBillingController(
return TypedResults.Unauthorized();
}
var providerSubscriptionDTO = await providerBillingQueries.GetSubscriptionDTO(providerId);
var providerSubscriptionDTO = await providerBillingService.GetSubscriptionDTO(providerId);
if (providerSubscriptionDTO == null)
{
@ -41,4 +41,31 @@ public class ProviderBillingController(
return TypedResults.Ok(providerSubscriptionResponse);
}
[HttpGet("payment-information")]
public async Task<IResult> GetPaymentInformationAsync([FromRoute] Guid providerId)
{
if (!featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling))
{
return TypedResults.NotFound();
}
if (!currentContext.ProviderProviderAdmin(providerId))
{
return TypedResults.Unauthorized();
}
var providerPaymentInformationDto = await providerBillingService.GetPaymentInformationAsync(providerId);
if (providerPaymentInformationDto == null)
{
return TypedResults.NotFound();
}
var (paymentSource, taxInfo) = providerPaymentInformationDto;
var providerPaymentInformationResponse = PaymentInformationResponse.From(paymentSource, taxInfo);
return TypedResults.Ok(providerPaymentInformationResponse);
}
}

View File

@ -2,7 +2,7 @@
using Bit.Core;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Models.Business;
@ -14,16 +14,14 @@ namespace Bit.Api.Billing.Controllers;
[Route("providers/{providerId:guid}/clients")]
public class ProviderClientsController(
IAssignSeatsToClientOrganizationCommand assignSeatsToClientOrganizationCommand,
ICreateCustomerCommand createCustomerCommand,
ICurrentContext currentContext,
IFeatureService featureService,
ILogger<ProviderClientsController> logger,
IOrganizationRepository organizationRepository,
IProviderBillingService providerBillingService,
IProviderOrganizationRepository providerOrganizationRepository,
IProviderRepository providerRepository,
IProviderService providerService,
IScaleSeatsCommand scaleSeatsCommand,
IUserService userService) : Controller
{
[HttpPost]
@ -83,12 +81,12 @@ public class ProviderClientsController(
return TypedResults.Problem();
}
await scaleSeatsCommand.ScalePasswordManagerSeats(
await providerBillingService.ScaleSeats(
provider,
requestBody.PlanType,
requestBody.Seats);
await createCustomerCommand.CreateCustomer(
await providerBillingService.CreateCustomerForClientOrganization(
provider,
clientOrganization);
@ -135,7 +133,7 @@ public class ProviderClientsController(
if (clientOrganization.Seats != requestBody.AssignedSeats)
{
await assignSeatsToClientOrganizationCommand.AssignSeatsToClientOrganization(
await providerBillingService.AssignSeatsToClientOrganization(
provider,
clientOrganization,
requestBody.AssignedSeats);

View File

@ -0,0 +1,37 @@
using Bit.Core.Enums;
using Bit.Core.Models.Business;
namespace Bit.Api.Billing.Models.Responses;
public record PaymentInformationResponse(PaymentMethod PaymentMethod, TaxInformation 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 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);

View File

@ -1,21 +0,0 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
namespace Bit.Core.Billing.Commands;
public interface IAssignSeatsToClientOrganizationCommand
{
/// <summary>
/// Assigns a specified number of <paramref name="seats"/> to a client <paramref name="organization"/> on behalf of
/// its <paramref name="provider"/>. Seat adjustments for the client organization may autoscale the provider's Stripe
/// <see cref="Stripe.Subscription"/> depending on the provider's seat minimum for the client <paramref name="organization"/>'s
/// <see cref="Organization.PlanType"/>.
/// </summary>
/// <param name="provider">The MSP that manages the client <paramref name="organization"/>.</param>
/// <param name="organization">The client organization whose <see cref="seats"/> you want to update.</param>
/// <param name="seats">The number of seats to assign to the client organization.</param>
Task AssignSeatsToClientOrganization(
Provider provider,
Organization organization,
int seats);
}

View File

@ -1,23 +0,0 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Models;
using Bit.Core.Entities;
using Stripe;
namespace Bit.Core.Billing.Commands;
public interface ICancelSubscriptionCommand
{
/// <summary>
/// Cancels a user or organization's subscription while including user-provided feedback via the <paramref name="offboardingSurveyResponse"/>.
/// If the <paramref name="cancelImmediately"/> flag is <see langword="false"/>,
/// this command sets the subscription's <b>"cancel_at_end_of_period"</b> property to <see langword="true"/>.
/// Otherwise, this command cancels the subscription immediately.
/// </summary>
/// <param name="subscription">The <see cref="User"/> or <see cref="Organization"/> with the subscription to cancel.</param>
/// <param name="offboardingSurveyResponse">An <see cref="OffboardingSurveyResponse"/> DTO containing user-provided feedback on why they are cancelling the subscription.</param>
/// <param name="cancelImmediately">A flag indicating whether to cancel the subscription immediately or at the end of the subscription period.</param>
Task CancelSubscription(
Subscription subscription,
OffboardingSurveyResponse offboardingSurveyResponse,
bool cancelImmediately);
}

View File

@ -1,17 +0,0 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
namespace Bit.Core.Billing.Commands;
public interface ICreateCustomerCommand
{
/// <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"/>.
/// </summary>
/// <param name="provider">The MSP that owns the client organization.</param>
/// <param name="organization">The client organization to create a Stripe <see cref="Stripe.Customer"/> for.</param>
Task CreateCustomer(
Provider provider,
Organization organization);
}

View File

@ -1,15 +0,0 @@
using Bit.Core.AdminConsole.Entities;
namespace Bit.Core.Billing.Commands;
public interface IRemovePaymentMethodCommand
{
/// <summary>
/// Attempts to remove an Organization's saved payment method. If the Stripe <see cref="Stripe.Customer"/> representing the
/// <see cref="Organization"/> contains a valid <b>"btCustomerId"</b> key in its <see cref="Stripe.Customer.Metadata"/> property,
/// this command will attempt to remove the Braintree <see cref="Braintree.PaymentMethod"/>. Otherwise, it will attempt to remove the
/// Stripe <see cref="Stripe.PaymentMethod"/>.
/// </summary>
/// <param name="organization">The organization to remove the saved payment method for.</param>
Task RemovePaymentMethod(Organization organization);
}

View File

@ -1,12 +0,0 @@
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Enums;
namespace Bit.Core.Billing.Commands;
public interface IScaleSeatsCommand
{
Task ScalePasswordManagerSeats(
Provider provider,
PlanType planType,
int seatAdjustment);
}

View File

@ -1,20 +0,0 @@
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Enums;
using Bit.Core.Models.Business;
namespace Bit.Core.Billing.Commands;
public interface IStartSubscriptionCommand
{
/// <summary>
/// Starts a Stripe <see cref="Stripe.Subscription"/> for the given <paramref name="provider"/> utilizing the provided
/// <paramref name="taxInfo"/> to handle automatic taxation and non-US tax identification. <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>
/// <param name="taxInfo">The tax information to use for automatic taxation and non-US tax identification.</param>
Task StartSubscription(
Provider provider,
TaxInfo taxInfo);
}

View File

@ -1,174 +0,0 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Queries;
using Bit.Core.Billing.Repositories;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.Extensions.Logging;
using static Bit.Core.Billing.Utilities;
namespace Bit.Core.Billing.Commands.Implementations;
public class AssignSeatsToClientOrganizationCommand(
ILogger<AssignSeatsToClientOrganizationCommand> logger,
IOrganizationRepository organizationRepository,
IPaymentService paymentService,
IProviderBillingQueries providerBillingQueries,
IProviderPlanRepository providerPlanRepository) : IAssignSeatsToClientOrganizationCommand
{
public async Task AssignSeatsToClientOrganization(
Provider provider,
Organization organization,
int seats)
{
ArgumentNullException.ThrowIfNull(provider);
ArgumentNullException.ThrowIfNull(organization);
if (provider.Type == ProviderType.Reseller)
{
logger.LogError("Reseller-type provider ({ID}) cannot assign seats to client organizations", provider.Id);
throw ContactSupport("Consolidated billing does not support reseller-type providers");
}
if (seats < 0)
{
throw new BillingException(
"You cannot assign negative seats to a client.",
"MSP cannot assign negative seats to a client organization");
}
if (seats == organization.Seats)
{
logger.LogWarning("Client organization ({ID}) already has {Seats} seats assigned", organization.Id, organization.Seats);
return;
}
var providerPlan = await GetProviderPlanAsync(provider, organization);
var providerSeatMinimum = providerPlan.SeatMinimum.GetValueOrDefault(0);
// How many seats the provider has assigned to all their client organizations that have the specified plan type.
var providerCurrentlyAssignedSeatTotal = await providerBillingQueries.GetAssignedSeatTotalForPlanOrThrow(provider.Id, providerPlan.PlanType);
// How many seats are being added to or subtracted from this client organization.
var seatDifference = seats - (organization.Seats ?? 0);
// How many seats the provider will have assigned to all of their client organizations after the update.
var providerNewlyAssignedSeatTotal = providerCurrentlyAssignedSeatTotal + seatDifference;
var update = CurryUpdateFunction(
provider,
providerPlan,
organization,
seats,
providerNewlyAssignedSeatTotal);
/*
* Below the limit => Below the limit:
* No subscription update required. We can safely update the organization's seats.
*/
if (providerCurrentlyAssignedSeatTotal <= providerSeatMinimum &&
providerNewlyAssignedSeatTotal <= providerSeatMinimum)
{
organization.Seats = seats;
await organizationRepository.ReplaceAsync(organization);
providerPlan.AllocatedSeats = providerNewlyAssignedSeatTotal;
await providerPlanRepository.ReplaceAsync(providerPlan);
}
/*
* Below the limit => Above the limit:
* We have to scale the subscription up from the seat minimum to the newly assigned seat total.
*/
else if (providerCurrentlyAssignedSeatTotal <= providerSeatMinimum &&
providerNewlyAssignedSeatTotal > providerSeatMinimum)
{
await update(
providerSeatMinimum,
providerNewlyAssignedSeatTotal);
}
/*
* Above the limit => Above the limit:
* We have to scale the subscription from the currently assigned seat total to the newly assigned seat total.
*/
else if (providerCurrentlyAssignedSeatTotal > providerSeatMinimum &&
providerNewlyAssignedSeatTotal > providerSeatMinimum)
{
await update(
providerCurrentlyAssignedSeatTotal,
providerNewlyAssignedSeatTotal);
}
/*
* Above the limit => Below the limit:
* We have to scale the subscription down from the currently assigned seat total to the seat minimum.
*/
else if (providerCurrentlyAssignedSeatTotal > providerSeatMinimum &&
providerNewlyAssignedSeatTotal <= providerSeatMinimum)
{
await update(
providerCurrentlyAssignedSeatTotal,
providerSeatMinimum);
}
}
// ReSharper disable once SuggestBaseTypeForParameter
private async Task<ProviderPlan> GetProviderPlanAsync(Provider provider, Organization organization)
{
if (!organization.PlanType.SupportsConsolidatedBilling())
{
logger.LogError("Cannot assign seats to a client organization ({ID}) with a plan type that does not support consolidated billing: {PlanType}", organization.Id, organization.PlanType);
throw ContactSupport();
}
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
var providerPlan = providerPlans.FirstOrDefault(providerPlan => providerPlan.PlanType == organization.PlanType);
if (providerPlan != null && providerPlan.IsConfigured())
{
return providerPlan;
}
logger.LogError("Cannot assign seats to client organization ({ClientOrganizationID}) when provider's ({ProviderID}) matching plan is not configured", organization.Id, provider.Id);
throw ContactSupport();
}
private Func<int, int, Task> CurryUpdateFunction(
Provider provider,
ProviderPlan providerPlan,
Organization organization,
int organizationNewlyAssignedSeats,
int providerNewlyAssignedSeats) => async (providerCurrentlySubscribedSeats, providerNewlySubscribedSeats) =>
{
var plan = StaticStore.GetPlan(providerPlan.PlanType);
await paymentService.AdjustSeats(
provider,
plan,
providerCurrentlySubscribedSeats,
providerNewlySubscribedSeats);
organization.Seats = organizationNewlyAssignedSeats;
await organizationRepository.ReplaceAsync(organization);
var providerNewlyPurchasedSeats = providerNewlySubscribedSeats > providerPlan.SeatMinimum
? providerNewlySubscribedSeats - providerPlan.SeatMinimum
: 0;
providerPlan.PurchasedSeats = providerNewlyPurchasedSeats;
providerPlan.AllocatedSeats = providerNewlyAssignedSeats;
await providerPlanRepository.ReplaceAsync(providerPlan);
};
}

View File

@ -1,118 +0,0 @@
using Bit.Core.Billing.Models;
using Bit.Core.Services;
using Microsoft.Extensions.Logging;
using Stripe;
using static Bit.Core.Billing.Utilities;
namespace Bit.Core.Billing.Commands.Implementations;
public class CancelSubscriptionCommand(
ILogger<CancelSubscriptionCommand> logger,
IStripeAdapter stripeAdapter)
: ICancelSubscriptionCommand
{
private static readonly List<string> _validReasons =
[
"customer_service",
"low_quality",
"missing_features",
"other",
"switched_service",
"too_complex",
"too_expensive",
"unused"
];
public async Task CancelSubscription(
Subscription subscription,
OffboardingSurveyResponse offboardingSurveyResponse,
bool cancelImmediately)
{
if (IsInactive(subscription))
{
logger.LogWarning("Cannot cancel subscription ({ID}) that's already inactive.", subscription.Id);
throw ContactSupport();
}
var metadata = new Dictionary<string, string>
{
{ "cancellingUserId", offboardingSurveyResponse.UserId.ToString() }
};
if (cancelImmediately)
{
if (BelongsToOrganization(subscription))
{
await stripeAdapter.SubscriptionUpdateAsync(subscription.Id, new SubscriptionUpdateOptions
{
Metadata = metadata
});
}
await CancelSubscriptionImmediatelyAsync(subscription.Id, offboardingSurveyResponse);
}
else
{
await CancelSubscriptionAtEndOfPeriodAsync(subscription.Id, offboardingSurveyResponse, metadata);
}
}
private static bool BelongsToOrganization(IHasMetadata subscription)
=> subscription.Metadata != null && subscription.Metadata.ContainsKey("organizationId");
private async Task CancelSubscriptionImmediatelyAsync(
string subscriptionId,
OffboardingSurveyResponse offboardingSurveyResponse)
{
var options = new SubscriptionCancelOptions
{
CancellationDetails = new SubscriptionCancellationDetailsOptions
{
Comment = offboardingSurveyResponse.Feedback
}
};
if (IsValidCancellationReason(offboardingSurveyResponse.Reason))
{
options.CancellationDetails.Feedback = offboardingSurveyResponse.Reason;
}
await stripeAdapter.SubscriptionCancelAsync(subscriptionId, options);
}
private static bool IsInactive(Subscription subscription) =>
subscription.CanceledAt.HasValue ||
subscription.Status == "canceled" ||
subscription.Status == "unpaid" ||
subscription.Status == "incomplete_expired";
private static bool IsValidCancellationReason(string reason) => _validReasons.Contains(reason);
private async Task CancelSubscriptionAtEndOfPeriodAsync(
string subscriptionId,
OffboardingSurveyResponse offboardingSurveyResponse,
Dictionary<string, string> metadata = null)
{
var options = new SubscriptionUpdateOptions
{
CancelAtPeriodEnd = true,
CancellationDetails = new SubscriptionCancellationDetailsOptions
{
Comment = offboardingSurveyResponse.Feedback
}
};
if (IsValidCancellationReason(offboardingSurveyResponse.Reason))
{
options.CancellationDetails.Feedback = offboardingSurveyResponse.Reason;
}
if (metadata != null)
{
options.Metadata = metadata;
}
await stripeAdapter.SubscriptionUpdateAsync(subscriptionId, options);
}
}

View File

@ -1,89 +0,0 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Billing.Queries;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Microsoft.Extensions.Logging;
using Stripe;
namespace Bit.Core.Billing.Commands.Implementations;
public class CreateCustomerCommand(
IGlobalSettings globalSettings,
ILogger<CreateCustomerCommand> logger,
IOrganizationRepository organizationRepository,
IStripeAdapter stripeAdapter,
ISubscriberQueries subscriberQueries) : ICreateCustomerCommand
{
public async Task CreateCustomer(
Provider provider,
Organization organization)
{
ArgumentNullException.ThrowIfNull(provider);
ArgumentNullException.ThrowIfNull(organization);
if (!string.IsNullOrEmpty(organization.GatewayCustomerId))
{
logger.LogWarning("Client organization ({ID}) already has a populated {FieldName}", organization.Id, nameof(organization.GatewayCustomerId));
return;
}
var providerCustomer = await subscriberQueries.GetCustomerOrThrow(provider, new CustomerGetOptions
{
Expand = ["tax_ids"]
});
var providerTaxId = providerCustomer.TaxIds.FirstOrDefault();
var organizationDisplayName = organization.DisplayName();
var customerCreateOptions = new CustomerCreateOptions
{
Address = new AddressOptions
{
Country = providerCustomer.Address?.Country,
PostalCode = providerCustomer.Address?.PostalCode,
Line1 = providerCustomer.Address?.Line1,
Line2 = providerCustomer.Address?.Line2,
City = providerCustomer.Address?.City,
State = providerCustomer.Address?.State
},
Name = organizationDisplayName,
Description = $"{provider.Name} Client Organization",
Email = provider.BillingEmail,
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
CustomFields =
[
new CustomerInvoiceSettingsCustomFieldOptions
{
Name = organization.SubscriberType(),
Value = organizationDisplayName.Length <= 30
? organizationDisplayName
: organizationDisplayName[..30]
}
]
},
Metadata = new Dictionary<string, string>
{
{ "region", globalSettings.BaseServiceUri.CloudRegion }
},
TaxIdData = providerTaxId == null ? null :
[
new CustomerTaxIdDataOptions
{
Type = providerTaxId.Type,
Value = providerTaxId.Value
}
]
};
var customer = await stripeAdapter.CustomerCreateAsync(customerCreateOptions);
organization.GatewayCustomerId = customer.Id;
await organizationRepository.ReplaceAsync(organization);
}
}

View File

@ -1,124 +0,0 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Enums;
using Bit.Core.Services;
using Braintree;
using Microsoft.Extensions.Logging;
using static Bit.Core.Billing.Utilities;
namespace Bit.Core.Billing.Commands.Implementations;
public class RemovePaymentMethodCommand(
IBraintreeGateway braintreeGateway,
ILogger<RemovePaymentMethodCommand> logger,
IStripeAdapter stripeAdapter)
: IRemovePaymentMethodCommand
{
public async Task RemovePaymentMethod(Organization organization)
{
ArgumentNullException.ThrowIfNull(organization);
if (organization.Gateway is not GatewayType.Stripe || string.IsNullOrEmpty(organization.GatewayCustomerId))
{
throw ContactSupport();
}
var stripeCustomer = await stripeAdapter.CustomerGetAsync(organization.GatewayCustomerId, new Stripe.CustomerGetOptions
{
Expand = ["invoice_settings.default_payment_method", "sources"]
});
if (stripeCustomer == null)
{
logger.LogError("Could not find Stripe customer ({ID}) when removing payment method", organization.GatewayCustomerId);
throw ContactSupport();
}
if (stripeCustomer.Metadata?.TryGetValue(BraintreeCustomerIdKey, out var braintreeCustomerId) ?? false)
{
await RemoveBraintreePaymentMethodAsync(braintreeCustomerId);
}
else
{
await RemoveStripePaymentMethodsAsync(stripeCustomer);
}
}
private async Task RemoveBraintreePaymentMethodAsync(string braintreeCustomerId)
{
var customer = await braintreeGateway.Customer.FindAsync(braintreeCustomerId);
if (customer == null)
{
logger.LogError("Failed to retrieve Braintree customer ({ID}) when removing payment method", braintreeCustomerId);
throw ContactSupport();
}
if (customer.DefaultPaymentMethod != null)
{
var existingDefaultPaymentMethod = customer.DefaultPaymentMethod;
var updateCustomerResult = await braintreeGateway.Customer.UpdateAsync(
braintreeCustomerId,
new CustomerRequest { DefaultPaymentMethodToken = null });
if (!updateCustomerResult.IsSuccess())
{
logger.LogError("Failed to update payment method for Braintree customer ({ID}) | Message: {Message}",
braintreeCustomerId, updateCustomerResult.Message);
throw ContactSupport();
}
var deletePaymentMethodResult = await braintreeGateway.PaymentMethod.DeleteAsync(existingDefaultPaymentMethod.Token);
if (!deletePaymentMethodResult.IsSuccess())
{
await braintreeGateway.Customer.UpdateAsync(
braintreeCustomerId,
new CustomerRequest { DefaultPaymentMethodToken = existingDefaultPaymentMethod.Token });
logger.LogError(
"Failed to delete Braintree payment method for Customer ({ID}), re-linked payment method. Message: {Message}",
braintreeCustomerId, deletePaymentMethodResult.Message);
throw ContactSupport();
}
}
else
{
logger.LogWarning("Tried to remove non-existent Braintree payment method for Customer ({ID})", braintreeCustomerId);
}
}
private async Task RemoveStripePaymentMethodsAsync(Stripe.Customer customer)
{
if (customer.Sources != null && customer.Sources.Any())
{
foreach (var source in customer.Sources)
{
switch (source)
{
case Stripe.BankAccount:
await stripeAdapter.BankAccountDeleteAsync(customer.Id, source.Id);
break;
case Stripe.Card:
await stripeAdapter.CardDeleteAsync(customer.Id, source.Id);
break;
}
}
}
var paymentMethods = stripeAdapter.PaymentMethodListAutoPagingAsync(new Stripe.PaymentMethodListOptions
{
Customer = customer.Id
});
await foreach (var paymentMethod in paymentMethods)
{
await stripeAdapter.PaymentMethodDetachAsync(paymentMethod.Id, new Stripe.PaymentMethodDetachOptions());
}
}
}

View File

@ -1,130 +0,0 @@
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Queries;
using Bit.Core.Billing.Repositories;
using Bit.Core.Enums;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.Extensions.Logging;
using static Bit.Core.Billing.Utilities;
namespace Bit.Core.Billing.Commands.Implementations;
public class ScaleSeatsCommand(
ILogger<ScaleSeatsCommand> logger,
IPaymentService paymentService,
IProviderBillingQueries providerBillingQueries,
IProviderPlanRepository providerPlanRepository) : IScaleSeatsCommand
{
public async Task ScalePasswordManagerSeats(Provider provider, PlanType planType, int seatAdjustment)
{
ArgumentNullException.ThrowIfNull(provider);
if (provider.Type != ProviderType.Msp)
{
logger.LogError("Non-MSP provider ({ProviderID}) cannot scale their Password Manager seats", provider.Id);
throw ContactSupport();
}
if (!planType.SupportsConsolidatedBilling())
{
logger.LogError("Cannot scale provider ({ProviderID}) Password Manager seats for plan type {PlanType} as it does not support consolidated billing", provider.Id, planType.ToString());
throw ContactSupport();
}
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
var providerPlan = providerPlans.FirstOrDefault(providerPlan => providerPlan.PlanType == planType);
if (providerPlan == null || !providerPlan.IsConfigured())
{
logger.LogError("Cannot scale provider ({ProviderID}) Password Manager seats for plan type {PlanType} when their matching provider plan is not configured", provider.Id, planType);
throw ContactSupport();
}
var seatMinimum = providerPlan.SeatMinimum.GetValueOrDefault(0);
var currentlyAssignedSeatTotal =
await providerBillingQueries.GetAssignedSeatTotalForPlanOrThrow(provider.Id, planType);
var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment;
var update = CurryUpdateFunction(
provider,
providerPlan,
newlyAssignedSeatTotal);
/*
* Below the limit => Below the limit:
* No subscription update required. We can safely update the organization's seats.
*/
if (currentlyAssignedSeatTotal <= seatMinimum &&
newlyAssignedSeatTotal <= seatMinimum)
{
providerPlan.AllocatedSeats = newlyAssignedSeatTotal;
await providerPlanRepository.ReplaceAsync(providerPlan);
}
/*
* Below the limit => Above the limit:
* We have to scale the subscription up from the seat minimum to the newly assigned seat total.
*/
else if (currentlyAssignedSeatTotal <= seatMinimum &&
newlyAssignedSeatTotal > seatMinimum)
{
await update(
seatMinimum,
newlyAssignedSeatTotal);
}
/*
* Above the limit => Above the limit:
* We have to scale the subscription from the currently assigned seat total to the newly assigned seat total.
*/
else if (currentlyAssignedSeatTotal > seatMinimum &&
newlyAssignedSeatTotal > seatMinimum)
{
await update(
currentlyAssignedSeatTotal,
newlyAssignedSeatTotal);
}
/*
* Above the limit => Below the limit:
* We have to scale the subscription down from the currently assigned seat total to the seat minimum.
*/
else if (currentlyAssignedSeatTotal > seatMinimum &&
newlyAssignedSeatTotal <= seatMinimum)
{
await update(
currentlyAssignedSeatTotal,
seatMinimum);
}
}
private Func<int, int, Task> CurryUpdateFunction(
Provider provider,
ProviderPlan providerPlan,
int newlyAssignedSeats) => async (currentlySubscribedSeats, newlySubscribedSeats) =>
{
var plan = StaticStore.GetPlan(providerPlan.PlanType);
await paymentService.AdjustSeats(
provider,
plan,
currentlySubscribedSeats,
newlySubscribedSeats);
var newlyPurchasedSeats = newlySubscribedSeats > providerPlan.SeatMinimum
? newlySubscribedSeats - providerPlan.SeatMinimum
: 0;
providerPlan.PurchasedSeats = newlyPurchasedSeats;
providerPlan.AllocatedSeats = newlyAssignedSeats;
await providerPlanRepository.ReplaceAsync(providerPlan);
};
}

View File

@ -1,202 +0,0 @@
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Repositories;
using Bit.Core.Enums;
using Bit.Core.Models.Business;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.Extensions.Logging;
using Stripe;
using static Bit.Core.Billing.Utilities;
namespace Bit.Core.Billing.Commands.Implementations;
public class StartSubscriptionCommand(
IGlobalSettings globalSettings,
ILogger<StartSubscriptionCommand> logger,
IProviderPlanRepository providerPlanRepository,
IProviderRepository providerRepository,
IStripeAdapter stripeAdapter) : IStartSubscriptionCommand
{
public async Task StartSubscription(
Provider provider,
TaxInfo taxInfo)
{
ArgumentNullException.ThrowIfNull(provider);
ArgumentNullException.ThrowIfNull(taxInfo);
if (!string.IsNullOrEmpty(provider.GatewaySubscriptionId))
{
logger.LogWarning("Cannot start Provider subscription - Provider ({ID}) already has a {FieldName}", provider.Id, nameof(provider.GatewaySubscriptionId));
throw ContactSupport();
}
if (string.IsNullOrEmpty(taxInfo.BillingAddressCountry) ||
string.IsNullOrEmpty(taxInfo.BillingAddressPostalCode))
{
logger.LogError("Cannot start Provider subscription - Both the Provider's ({ID}) country and postal code are required", provider.Id);
throw ContactSupport();
}
var customer = await GetOrCreateCustomerAsync(provider, taxInfo);
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
if (providerPlans == null || providerPlans.Count == 0)
{
logger.LogError("Cannot start Provider subscription - Provider ({ID}) has no configured plans", provider.Id);
throw ContactSupport();
}
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>();
var teamsProviderPlan =
providerPlans.SingleOrDefault(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly);
if (teamsProviderPlan == null)
{
logger.LogError("Cannot start Provider subscription - Provider ({ID}) has no configured Teams Monthly plan", provider.Id);
throw ContactSupport();
}
var teamsPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Price = teamsPlan.PasswordManager.StripeSeatPlanId,
Quantity = teamsProviderPlan.SeatMinimum
});
var enterpriseProviderPlan =
providerPlans.SingleOrDefault(providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly);
if (enterpriseProviderPlan == null)
{
logger.LogError("Cannot start Provider subscription - Provider ({ID}) has no configured Enterprise Monthly plan", provider.Id);
throw ContactSupport();
}
var enterprisePlan = StaticStore.GetPlan(PlanType.EnterpriseMonthly);
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Price = enterprisePlan.PasswordManager.StripeSeatPlanId,
Quantity = enterpriseProviderPlan.SeatMinimum
});
var subscriptionCreateOptions = new SubscriptionCreateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = true
},
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
Customer = customer.Id,
DaysUntilDue = 30,
Items = subscriptionItemOptionsList,
Metadata = new Dictionary<string, string>
{
{ "providerId", provider.Id.ToString() }
},
OffSession = true,
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations
};
var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
provider.GatewaySubscriptionId = subscription.Id;
if (subscription.Status == StripeConstants.SubscriptionStatus.Incomplete)
{
await providerRepository.ReplaceAsync(provider);
logger.LogError("Started incomplete Provider ({ProviderID}) subscription ({SubscriptionID})", provider.Id, subscription.Id);
throw ContactSupport();
}
provider.Status = ProviderStatusType.Billable;
await providerRepository.ReplaceAsync(provider);
}
// ReSharper disable once SuggestBaseTypeForParameter
private async Task<Customer> GetOrCreateCustomerAsync(
Provider provider,
TaxInfo taxInfo)
{
if (!string.IsNullOrEmpty(provider.GatewayCustomerId))
{
var existingCustomer = await stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId, new CustomerGetOptions
{
Expand = ["tax"]
});
if (existingCustomer != null)
{
return existingCustomer;
}
logger.LogError("Cannot start Provider subscription - Provider's ({ProviderID}) {CustomerIDFieldName} did not relate to a Stripe customer", provider.Id, nameof(provider.GatewayCustomerId));
throw ContactSupport();
}
var providerDisplayName = provider.DisplayName();
var customerCreateOptions = new CustomerCreateOptions
{
Address = new AddressOptions
{
Country = taxInfo.BillingAddressCountry,
PostalCode = taxInfo.BillingAddressPostalCode,
Line1 = taxInfo.BillingAddressLine1,
Line2 = taxInfo.BillingAddressLine2,
City = taxInfo.BillingAddressCity,
State = taxInfo.BillingAddressState
},
Coupon = "msp-discount-35",
Description = provider.DisplayBusinessName(),
Email = provider.BillingEmail,
Expand = ["tax"],
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
CustomFields =
[
new CustomerInvoiceSettingsCustomFieldOptions
{
Name = provider.SubscriberType(),
Value = providerDisplayName.Length <= 30
? providerDisplayName
: providerDisplayName[..30]
}
]
},
Metadata = new Dictionary<string, string>
{
{ "region", globalSettings.BaseServiceUri.CloudRegion }
},
TaxIdData = taxInfo.HasTaxId ?
[
new CustomerTaxIdDataOptions { Type = taxInfo.TaxIdType, Value = taxInfo.TaxIdNumber }
]
: null
};
var createdCustomer = await stripeAdapter.CustomerCreateAsync(customerCreateOptions);
provider.GatewayCustomerId = createdCustomer.Id;
await providerRepository.ReplaceAsync(provider);
return createdCustomer;
}
}

View File

@ -1,7 +1,5 @@
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Commands.Implementations;
using Bit.Core.Billing.Queries;
using Bit.Core.Billing.Queries.Implementations;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Implementations;
namespace Bit.Core.Billing.Extensions;
@ -11,17 +9,7 @@ public static class ServiceCollectionExtensions
{
public static void AddBillingOperations(this IServiceCollection services)
{
// Queries
services.AddTransient<IOrganizationBillingQueries, OrganizationBillingQueries>();
services.AddTransient<IProviderBillingQueries, ProviderBillingQueries>();
services.AddTransient<ISubscriberQueries, SubscriberQueries>();
// Commands
services.AddTransient<IAssignSeatsToClientOrganizationCommand, AssignSeatsToClientOrganizationCommand>();
services.AddTransient<ICancelSubscriptionCommand, CancelSubscriptionCommand>();
services.AddTransient<ICreateCustomerCommand, CreateCustomerCommand>();
services.AddTransient<IRemovePaymentMethodCommand, RemovePaymentMethodCommand>();
services.AddTransient<IScaleSeatsCommand, ScaleSeatsCommand>();
services.AddTransient<IStartSubscriptionCommand, StartSubscriptionCommand>();
services.AddTransient<IOrganizationBillingService, OrganizationBillingService>();
services.AddTransient<ISubscriberService, SubscriberService>();
}
}

View File

@ -0,0 +1,6 @@
using Bit.Core.Models.Business;
namespace Bit.Core.Billing.Models;
public record ProviderPaymentInfoDTO(BillingInfo.BillingSource billingSource,
TaxInfo taxInfo);

View File

@ -1,27 +0,0 @@
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Billing.Models;
using Bit.Core.Enums;
namespace Bit.Core.Billing.Queries;
public interface IProviderBillingQueries
{
/// <summary>
/// Retrieves the number of seats an MSP has assigned to its client organizations with a specified <paramref name="planType"/>.
/// </summary>
/// <param name="providerId">The ID of the MSP to retrieve the assigned seat total for.</param>
/// <param name="planType">The type of plan to retrieve the assigned seat total for.</param>
/// <returns>An <see cref="int"/> representing the number of seats the provider has assigned to its client organizations with the specified <paramref name="planType"/>.</returns>
/// <exception cref="BillingException">Thrown when the provider represented by the <paramref name="providerId"/> is <see langword="null"/>.</exception>
/// <exception cref="BillingException">Thrown when the provider represented by the <paramref name="providerId"/> has <see cref="Provider.Type"/> <see cref="ProviderType.Reseller"/>.</exception>
Task<int> GetAssignedSeatTotalForPlanOrThrow(Guid providerId, PlanType planType);
/// <summary>
/// Retrieves a provider's billing subscription data.
/// </summary>
/// <param name="providerId">The ID of the provider to retrieve subscription data for.</param>
/// <returns>A <see cref="ProviderSubscriptionDTO"/> object containing the provider's Stripe <see cref="Stripe.Subscription"/> and their <see cref="ConfiguredProviderPlanDTO"/>s.</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<ProviderSubscriptionDTO> GetSubscriptionDTO(Guid providerId);
}

View File

@ -1,58 +0,0 @@
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Stripe;
namespace Bit.Core.Billing.Queries;
public interface ISubscriberQueries
{
/// <summary>
/// Retrieves a Stripe <see cref="Customer"/> using the <paramref name="subscriber"/>'s <see cref="ISubscriber.GatewayCustomerId"/> property.
/// </summary>
/// <param name="subscriber">The organization, provider or user to retrieve the customer for.</param>
/// <param name="customerGetOptions">Optional parameters that can be passed to Stripe to expand or modify the <see cref="Customer"/>.</param>
/// <returns>A Stripe <see cref="Customer"/>.</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<Customer> GetCustomer(
ISubscriber subscriber,
CustomerGetOptions customerGetOptions = null);
/// <summary>
/// Retrieves a Stripe <see cref="Subscription"/> using the <paramref name="subscriber"/>'s <see cref="ISubscriber.GatewaySubscriptionId"/> property.
/// </summary>
/// <param name="subscriber">The organization, provider or user to retrieve the subscription for.</param>
/// <param name="subscriptionGetOptions">Optional parameters that can be passed to Stripe to expand or modify the <see cref="Subscription"/>.</param>
/// <returns>A Stripe <see cref="Subscription"/>.</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<Subscription> GetSubscription(
ISubscriber subscriber,
SubscriptionGetOptions subscriptionGetOptions = null);
/// <summary>
/// Retrieves a Stripe <see cref="Customer"/> using the <paramref name="subscriber"/>'s <see cref="ISubscriber.GatewayCustomerId"/> property.
/// </summary>
/// <param name="subscriber">The organization or user to retrieve the subscription for.</param>
/// <param name="customerGetOptions">Optional parameters that can be passed to Stripe to expand or modify the <see cref="Customer"/>.</param>
/// <returns>A Stripe <see cref="Customer"/>.</returns>
/// <exception cref="ArgumentNullException">Thrown when the <paramref name="subscriber"/> is <see langword="null"/>.</exception>
/// <exception cref="GatewayException">Thrown when the subscriber's <see cref="ISubscriber.GatewayCustomerId"/> is <see langword="null"/> or empty.</exception>
/// <exception cref="GatewayException">Thrown when the <see cref="Customer"/> returned from Stripe's API is null.</exception>
Task<Customer> GetCustomerOrThrow(
ISubscriber subscriber,
CustomerGetOptions customerGetOptions = null);
/// <summary>
/// Retrieves a Stripe <see cref="Subscription"/> using the <paramref name="subscriber"/>'s <see cref="ISubscriber.GatewaySubscriptionId"/> property.
/// </summary>
/// <param name="subscriber">The organization or user to retrieve the subscription for.</param>
/// <param name="subscriptionGetOptions">Optional parameters that can be passed to Stripe to expand or modify the <see cref="Subscription"/>.</param>
/// <returns>A Stripe <see cref="Subscription"/>.</returns>
/// <exception cref="ArgumentNullException">Thrown when the <paramref name="subscriber"/> is <see langword="null"/>.</exception>
/// <exception cref="GatewayException">Thrown when the subscriber's <see cref="ISubscriber.GatewaySubscriptionId"/> is <see langword="null"/> or empty.</exception>
/// <exception cref="GatewayException">Thrown when the <see cref="Subscription"/> returned from Stripe's API is null.</exception>
Task<Subscription> GetSubscriptionOrThrow(
ISubscriber subscriber,
SubscriptionGetOptions subscriptionGetOptions = null);
}

View File

@ -1,92 +0,0 @@
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Repositories;
using Bit.Core.Enums;
using Bit.Core.Utilities;
using Microsoft.Extensions.Logging;
using Stripe;
using static Bit.Core.Billing.Utilities;
namespace Bit.Core.Billing.Queries.Implementations;
public class ProviderBillingQueries(
ILogger<ProviderBillingQueries> logger,
IProviderOrganizationRepository providerOrganizationRepository,
IProviderPlanRepository providerPlanRepository,
IProviderRepository providerRepository,
ISubscriberQueries subscriberQueries) : IProviderBillingQueries
{
public async Task<int> GetAssignedSeatTotalForPlanOrThrow(
Guid providerId,
PlanType planType)
{
var provider = await providerRepository.GetByIdAsync(providerId);
if (provider == null)
{
logger.LogError(
"Could not find provider ({ID}) when retrieving assigned seat total",
providerId);
throw ContactSupport();
}
if (provider.Type == ProviderType.Reseller)
{
logger.LogError("Assigned seats cannot be retrieved for reseller-type provider ({ID})", providerId);
throw ContactSupport("Consolidated billing does not support reseller-type providers");
}
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId);
var plan = StaticStore.GetPlan(planType);
return providerOrganizations
.Where(providerOrganization => providerOrganization.Plan == plan.Name && providerOrganization.Status == OrganizationStatusType.Managed)
.Sum(providerOrganization => providerOrganization.Seats ?? 0);
}
public async Task<ProviderSubscriptionDTO> GetSubscriptionDTO(Guid providerId)
{
var provider = await providerRepository.GetByIdAsync(providerId);
if (provider == null)
{
logger.LogError(
"Could not find provider ({ID}) when retrieving subscription data.",
providerId);
return null;
}
if (provider.Type == ProviderType.Reseller)
{
logger.LogError("Subscription data cannot be retrieved for reseller-type provider ({ID})", providerId);
throw ContactSupport("Consolidated billing does not support reseller-type providers");
}
var subscription = await subscriberQueries.GetSubscription(provider, new SubscriptionGetOptions
{
Expand = ["customer"]
});
if (subscription == null)
{
return null;
}
var providerPlans = await providerPlanRepository.GetByProviderId(providerId);
var configuredProviderPlans = providerPlans
.Where(providerPlan => providerPlan.IsConfigured())
.Select(ConfiguredProviderPlanDTO.From)
.ToList();
return new ProviderSubscriptionDTO(
configuredProviderPlans,
subscription);
}
}

View File

@ -1,159 +0,0 @@
using Bit.Core.Entities;
using Bit.Core.Services;
using Microsoft.Extensions.Logging;
using Stripe;
using static Bit.Core.Billing.Utilities;
namespace Bit.Core.Billing.Queries.Implementations;
public class SubscriberQueries(
ILogger<SubscriberQueries> logger,
IStripeAdapter stripeAdapter) : ISubscriberQueries
{
public async Task<Customer> GetCustomer(
ISubscriber subscriber,
CustomerGetOptions customerGetOptions = null)
{
ArgumentNullException.ThrowIfNull(subscriber);
if (string.IsNullOrEmpty(subscriber.GatewayCustomerId))
{
logger.LogError("Cannot retrieve customer for subscriber ({SubscriberID}) with no {FieldName}", subscriber.Id, nameof(subscriber.GatewayCustomerId));
return null;
}
try
{
var customer = await stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId, customerGetOptions);
if (customer != null)
{
return customer;
}
logger.LogError("Could not find Stripe customer ({CustomerID}) for subscriber ({SubscriberID})",
subscriber.GatewayCustomerId, subscriber.Id);
return null;
}
catch (StripeException exception)
{
logger.LogError("An error occurred while trying to retrieve Stripe customer ({CustomerID}) for subscriber ({SubscriberID}): {Error}",
subscriber.GatewayCustomerId, subscriber.Id, exception.Message);
return null;
}
}
public async Task<Subscription> GetSubscription(
ISubscriber subscriber,
SubscriptionGetOptions subscriptionGetOptions = null)
{
ArgumentNullException.ThrowIfNull(subscriber);
if (string.IsNullOrEmpty(subscriber.GatewaySubscriptionId))
{
logger.LogError("Cannot retrieve subscription for subscriber ({SubscriberID}) with no {FieldName}", subscriber.Id, nameof(subscriber.GatewaySubscriptionId));
return null;
}
try
{
var subscription =
await stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, subscriptionGetOptions);
if (subscription != null)
{
return subscription;
}
logger.LogError("Could not find Stripe subscription ({SubscriptionID}) for subscriber ({SubscriberID})",
subscriber.GatewaySubscriptionId, subscriber.Id);
return null;
}
catch (StripeException exception)
{
logger.LogError("An error occurred while trying to retrieve Stripe subscription ({SubscriptionID}) for subscriber ({SubscriberID}): {Error}",
subscriber.GatewaySubscriptionId, subscriber.Id, exception.Message);
return null;
}
}
public async Task<Customer> GetCustomerOrThrow(
ISubscriber subscriber,
CustomerGetOptions customerGetOptions = null)
{
ArgumentNullException.ThrowIfNull(subscriber);
if (string.IsNullOrEmpty(subscriber.GatewayCustomerId))
{
logger.LogError("Cannot retrieve customer for subscriber ({SubscriberID}) with no {FieldName}", subscriber.Id, nameof(subscriber.GatewayCustomerId));
throw ContactSupport();
}
try
{
var customer = await stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId, customerGetOptions);
if (customer != null)
{
return customer;
}
logger.LogError("Could not find Stripe customer ({CustomerID}) for subscriber ({SubscriberID})",
subscriber.GatewayCustomerId, subscriber.Id);
throw ContactSupport();
}
catch (StripeException exception)
{
logger.LogError("An error occurred while trying to retrieve Stripe customer ({CustomerID}) for subscriber ({SubscriberID}): {Error}",
subscriber.GatewayCustomerId, subscriber.Id, exception.Message);
throw ContactSupport("An error occurred while trying to retrieve a Stripe Customer", exception);
}
}
public async Task<Subscription> GetSubscriptionOrThrow(
ISubscriber subscriber,
SubscriptionGetOptions subscriptionGetOptions = null)
{
ArgumentNullException.ThrowIfNull(subscriber);
if (string.IsNullOrEmpty(subscriber.GatewaySubscriptionId))
{
logger.LogError("Cannot retrieve subscription for subscriber ({SubscriberID}) with no {FieldName}", subscriber.Id, nameof(subscriber.GatewaySubscriptionId));
throw ContactSupport();
}
try
{
var subscription =
await stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, subscriptionGetOptions);
if (subscription != null)
{
return subscription;
}
logger.LogError("Could not find Stripe subscription ({SubscriptionID}) for subscriber ({SubscriberID})",
subscriber.GatewaySubscriptionId, subscriber.Id);
throw ContactSupport();
}
catch (StripeException exception)
{
logger.LogError("An error occurred while trying to retrieve Stripe subscription ({SubscriptionID}) for subscriber ({SubscriberID}): {Error}",
subscriber.GatewaySubscriptionId, subscriber.Id, exception.Message);
throw ContactSupport("An error occurred while trying to retrieve a Stripe Subscription", exception);
}
}
}

View File

@ -1,8 +1,8 @@
using Bit.Core.Billing.Models;
namespace Bit.Core.Billing.Queries;
namespace Bit.Core.Billing.Services;
public interface IOrganizationBillingQueries
public interface IOrganizationBillingService
{
Task<OrganizationMetadataDTO> GetMetadata(Guid organizationId);
}

View File

@ -0,0 +1,96 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Billing.Models;
using Bit.Core.Enums;
using Bit.Core.Models.Business;
namespace Bit.Core.Billing.Services;
public interface IProviderBillingService
{
/// <summary>
/// Assigns a specified number of <paramref name="seats"/> to a client <paramref name="organization"/> on behalf of
/// its <paramref name="provider"/>. Seat adjustments for the client organization may autoscale the provider's Stripe
/// <see cref="Stripe.Subscription"/> depending on the provider's seat minimum for the client <paramref name="organization"/>'s
/// <see cref="PlanType"/>.
/// </summary>
/// <param name="provider">The <see cref="Provider"/> that manages the client <paramref name="organization"/>.</param>
/// <param name="organization">The client <see cref="Organization"/> whose <see cref="seats"/> you want to update.</param>
/// <param name="seats">The number of seats to assign to the client organization.</param>
Task AssignSeatsToClientOrganization(
Provider provider,
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"/>.
/// </summary>
/// <param name="provider">The MSP that owns the client organization.</param>
/// <param name="organization">The client organization to create a Stripe <see cref="Stripe.Customer"/> for.</param>
Task CreateCustomerForClientOrganization(
Provider provider,
Organization organization);
/// <summary>
/// Retrieves the number of seats an MSP has assigned to its client organizations with a specified <paramref name="planType"/>.
/// </summary>
/// <param name="providerId">The ID of the MSP to retrieve the assigned seat total for.</param>
/// <param name="planType">The type of plan to retrieve the assigned seat total for.</param>
/// <returns>An <see cref="int"/> representing the number of seats the provider has assigned to its client organizations with the specified <paramref name="planType"/>.</returns>
/// <exception cref="BillingException">Thrown when the provider represented by the <paramref name="providerId"/> is <see langword="null"/>.</exception>
/// <exception cref="BillingException">Thrown when the provider represented by the <paramref name="providerId"/> has <see cref="Provider.Type"/> <see cref="ProviderType.Reseller"/>.</exception>
Task<int> GetAssignedSeatTotalForPlanOrThrow(
Guid providerId,
PlanType planType);
/// <summary>
/// Retrieves a provider's billing subscription data.
/// </summary>
/// <param name="providerId">The ID of the provider to retrieve subscription data for.</param>
/// <returns>A <see cref="ProviderSubscriptionDTO"/> object containing the provider's Stripe <see cref="Stripe.Subscription"/> and their <see cref="ConfiguredProviderPlanDTO"/>s.</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<ProviderSubscriptionDTO> GetSubscriptionDTO(
Guid providerId);
/// <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
/// specified <paramref name="planType"/>.
/// </summary>
/// <param name="provider">The <see cref="Provider"/> to scale seats for.</param>
/// <param name="planType">The <see cref="PlanType"/> to scale seats for.</param>
/// <param name="seatAdjustment">The change in the number of seats you'd like to apply to the <paramref name="provider"/>.</param>
Task ScaleSeats(
Provider provider,
PlanType planType,
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"/>.
/// <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(
Provider provider);
/// <summary>
/// Retrieves a provider's billing payment information.
/// </summary>
/// <param name="providerId">The ID of the provider to retrieve payment information for.</param>
/// <returns>A <see cref="ProviderPaymentInfoDTO"/> object containing the provider's Stripe <see cref="Stripe.PaymentMethod"/> and their <see cref="TaxInfo"/>s.</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<ProviderPaymentInfoDTO> GetPaymentInformationAsync(Guid providerId);
}

View File

@ -0,0 +1,100 @@
using Bit.Core.Billing.Models;
using Bit.Core.Entities;
using Bit.Core.Models.Business;
using Stripe;
namespace Bit.Core.Billing.Services;
public interface ISubscriberService
{
/// <summary>
/// Cancels a subscriber's subscription while including user-provided feedback via the <paramref name="offboardingSurveyResponse"/>.
/// If the <paramref name="cancelImmediately"/> flag is <see langword="false"/>,
/// this command sets the subscription's <b>"cancel_at_end_of_period"</b> property to <see langword="true"/>.
/// Otherwise, this command cancels the subscription immediately.
/// </summary>
/// <param name="subscriber">The subscriber with the subscription to cancel.</param>
/// <param name="offboardingSurveyResponse">An <see cref="OffboardingSurveyResponse"/> DTO containing user-provided feedback on why they are cancelling the subscription.</param>
/// <param name="cancelImmediately">A flag indicating whether to cancel the subscription immediately or at the end of the subscription period.</param>
Task CancelSubscription(
ISubscriber subscriber,
OffboardingSurveyResponse offboardingSurveyResponse,
bool cancelImmediately);
/// <summary>
/// Retrieves a Stripe <see cref="Customer"/> using the <paramref name="subscriber"/>'s <see cref="ISubscriber.GatewayCustomerId"/> property.
/// </summary>
/// <param name="subscriber">The subscriber to retrieve the Stripe customer for.</param>
/// <param name="customerGetOptions">Optional parameters that can be passed to Stripe to expand or modify the customer.</param>
/// <returns>A Stripe <see cref="Customer"/>.</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<Customer> GetCustomer(
ISubscriber subscriber,
CustomerGetOptions customerGetOptions = null);
/// <summary>
/// Retrieves a Stripe <see cref="Customer"/> using the <paramref name="subscriber"/>'s <see cref="ISubscriber.GatewayCustomerId"/> property.
/// </summary>
/// <param name="subscriber">The subscriber to retrieve the Stripe customer for.</param>
/// <param name="customerGetOptions">Optional parameters that can be passed to Stripe to expand or modify the customer.</param>
/// <returns>A Stripe <see cref="Customer"/>.</returns>
/// <exception cref="ArgumentNullException">Thrown when the <paramref name="subscriber"/> is <see langword="null"/>.</exception>
/// <exception cref="BillingException">Thrown when the subscriber's <see cref="ISubscriber.GatewayCustomerId"/> is <see langword="null"/> or empty.</exception>
/// <exception cref="BillingException">Thrown when the <see cref="Customer"/> returned from Stripe's API is null.</exception>
Task<Customer> GetCustomerOrThrow(
ISubscriber subscriber,
CustomerGetOptions customerGetOptions = null);
/// <summary>
/// Retrieves a Stripe <see cref="Subscription"/> using the <paramref name="subscriber"/>'s <see cref="ISubscriber.GatewaySubscriptionId"/> property.
/// </summary>
/// <param name="subscriber">The subscriber to retrieve the Stripe subscription for.</param>
/// <param name="subscriptionGetOptions">Optional parameters that can be passed to Stripe to expand or modify the subscription.</param>
/// <returns>A Stripe <see cref="Subscription"/>.</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<Subscription> GetSubscription(
ISubscriber subscriber,
SubscriptionGetOptions subscriptionGetOptions = null);
/// <summary>
/// Retrieves a Stripe <see cref="Subscription"/> using the <paramref name="subscriber"/>'s <see cref="ISubscriber.GatewaySubscriptionId"/> property.
/// </summary>
/// <param name="subscriber">The subscriber to retrieve the Stripe subscription for.</param>
/// <param name="subscriptionGetOptions">Optional parameters that can be passed to Stripe to expand or modify the subscription.</param>
/// <returns>A Stripe <see cref="Subscription"/>.</returns>
/// <exception cref="ArgumentNullException">Thrown when the <paramref name="subscriber"/> is <see langword="null"/>.</exception>
/// <exception cref="BillingException">Thrown when the subscriber's <see cref="ISubscriber.GatewaySubscriptionId"/> is <see langword="null"/> or empty.</exception>
/// <exception cref="BillingException">Thrown when the <see cref="Subscription"/> returned from Stripe's API is null.</exception>
Task<Subscription> GetSubscriptionOrThrow(
ISubscriber subscriber,
SubscriptionGetOptions subscriptionGetOptions = null);
/// <summary>
/// Attempts to remove a subscriber's saved payment method. If the Stripe <see cref="Stripe.Customer"/> representing the
/// <paramref name="subscriber"/> contains a valid <b>"btCustomerId"</b> key in its <see cref="Stripe.Customer.Metadata"/> property,
/// this command will attempt to remove the Braintree <see cref="Braintree.PaymentMethod"/>. Otherwise, it will attempt to remove the
/// Stripe <see cref="Stripe.PaymentMethod"/>.
/// </summary>
/// <param name="subscriber">The subscriber to remove the saved payment method for.</param>
Task RemovePaymentMethod(ISubscriber subscriber);
/// <summary>
/// Retrieves a Stripe <see cref="TaxInfo"/> using the <paramref name="subscriber"/>'s <see cref="ISubscriber.GatewayCustomerId"/> property.
/// </summary>
/// <param name="subscriber">The subscriber to retrieve the Stripe customer for.</param>
/// <returns>A Stripe <see cref="TaxInfo"/>.</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<TaxInfo> GetTaxInformationAsync(ISubscriber subscriber);
/// <summary>
/// Retrieves a Stripe <see cref="BillingInfo.BillingSource"/> using the <paramref name="subscriber"/>'s <see cref="ISubscriber.GatewayCustomerId"/> property.
/// </summary>
/// <param name="subscriber">The subscriber to retrieve the Stripe customer for.</param>
/// <returns>A Stripe <see cref="BillingInfo.BillingSource"/>.</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<BillingInfo.BillingSource> GetPaymentMethodAsync(ISubscriber subscriber);
}

View File

@ -5,11 +5,11 @@ using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Stripe;
namespace Bit.Core.Billing.Queries.Implementations;
namespace Bit.Core.Billing.Services.Implementations;
public class OrganizationBillingQueries(
public class OrganizationBillingService(
IOrganizationRepository organizationRepository,
ISubscriberQueries subscriberQueries) : IOrganizationBillingQueries
ISubscriberService subscriberService) : IOrganizationBillingService
{
public async Task<OrganizationMetadataDTO> GetMetadata(Guid organizationId)
{
@ -20,12 +20,12 @@ public class OrganizationBillingQueries(
return null;
}
var customer = await subscriberQueries.GetCustomer(organization, new CustomerGetOptions
var customer = await subscriberService.GetCustomer(organization, new CustomerGetOptions
{
Expand = ["discount.coupon.applies_to"]
});
var subscription = await subscriberQueries.GetSubscription(organization);
var subscription = await subscriberService.GetSubscription(organization);
if (customer == null || subscription == null)
{

View File

@ -0,0 +1,444 @@
using Bit.Core.Billing.Models;
using Bit.Core.Entities;
using Bit.Core.Models.Business;
using Bit.Core.Services;
using Braintree;
using Microsoft.Extensions.Logging;
using Stripe;
using static Bit.Core.Billing.Utilities;
using Customer = Stripe.Customer;
using Subscription = Stripe.Subscription;
namespace Bit.Core.Billing.Services.Implementations;
public class SubscriberService(
IBraintreeGateway braintreeGateway,
ILogger<SubscriberService> logger,
IStripeAdapter stripeAdapter) : ISubscriberService
{
public async Task CancelSubscription(
ISubscriber subscriber,
OffboardingSurveyResponse offboardingSurveyResponse,
bool cancelImmediately)
{
var subscription = await GetSubscriptionOrThrow(subscriber);
if (subscription.CanceledAt.HasValue ||
subscription.Status == "canceled" ||
subscription.Status == "unpaid" ||
subscription.Status == "incomplete_expired")
{
logger.LogWarning("Cannot cancel subscription ({ID}) that's already inactive", subscription.Id);
throw ContactSupport();
}
var metadata = new Dictionary<string, string>
{
{ "cancellingUserId", offboardingSurveyResponse.UserId.ToString() }
};
List<string> validCancellationReasons = [
"customer_service",
"low_quality",
"missing_features",
"other",
"switched_service",
"too_complex",
"too_expensive",
"unused"
];
if (cancelImmediately)
{
if (subscription.Metadata != null && subscription.Metadata.ContainsKey("organizationId"))
{
await stripeAdapter.SubscriptionUpdateAsync(subscription.Id, new SubscriptionUpdateOptions
{
Metadata = metadata
});
}
var options = new SubscriptionCancelOptions
{
CancellationDetails = new SubscriptionCancellationDetailsOptions
{
Comment = offboardingSurveyResponse.Feedback
}
};
if (validCancellationReasons.Contains(offboardingSurveyResponse.Reason))
{
options.CancellationDetails.Feedback = offboardingSurveyResponse.Reason;
}
await stripeAdapter.SubscriptionCancelAsync(subscription.Id, options);
}
else
{
var options = new SubscriptionUpdateOptions
{
CancelAtPeriodEnd = true,
CancellationDetails = new SubscriptionCancellationDetailsOptions
{
Comment = offboardingSurveyResponse.Feedback
},
Metadata = metadata
};
if (validCancellationReasons.Contains(offboardingSurveyResponse.Reason))
{
options.CancellationDetails.Feedback = offboardingSurveyResponse.Reason;
}
await stripeAdapter.SubscriptionUpdateAsync(subscription.Id, options);
}
}
public async Task<Customer> GetCustomer(
ISubscriber subscriber,
CustomerGetOptions customerGetOptions = null)
{
ArgumentNullException.ThrowIfNull(subscriber);
if (string.IsNullOrEmpty(subscriber.GatewayCustomerId))
{
logger.LogError("Cannot retrieve customer for subscriber ({SubscriberID}) with no {FieldName}", subscriber.Id, nameof(subscriber.GatewayCustomerId));
return null;
}
try
{
var customer = await stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId, customerGetOptions);
if (customer != null)
{
return customer;
}
logger.LogError("Could not find Stripe customer ({CustomerID}) for subscriber ({SubscriberID})",
subscriber.GatewayCustomerId, subscriber.Id);
return null;
}
catch (StripeException exception)
{
logger.LogError("An error occurred while trying to retrieve Stripe customer ({CustomerID}) for subscriber ({SubscriberID}): {Error}",
subscriber.GatewayCustomerId, subscriber.Id, exception.Message);
return null;
}
}
public async Task<Customer> GetCustomerOrThrow(
ISubscriber subscriber,
CustomerGetOptions customerGetOptions = null)
{
ArgumentNullException.ThrowIfNull(subscriber);
if (string.IsNullOrEmpty(subscriber.GatewayCustomerId))
{
logger.LogError("Cannot retrieve customer for subscriber ({SubscriberID}) with no {FieldName}", subscriber.Id, nameof(subscriber.GatewayCustomerId));
throw ContactSupport();
}
try
{
var customer = await stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId, customerGetOptions);
if (customer != null)
{
return customer;
}
logger.LogError("Could not find Stripe customer ({CustomerID}) for subscriber ({SubscriberID})",
subscriber.GatewayCustomerId, subscriber.Id);
throw ContactSupport();
}
catch (StripeException exception)
{
logger.LogError("An error occurred while trying to retrieve Stripe customer ({CustomerID}) for subscriber ({SubscriberID}): {Error}",
subscriber.GatewayCustomerId, subscriber.Id, exception.Message);
throw ContactSupport("An error occurred while trying to retrieve a Stripe Customer", exception);
}
}
public async Task<Subscription> GetSubscription(
ISubscriber subscriber,
SubscriptionGetOptions subscriptionGetOptions = null)
{
ArgumentNullException.ThrowIfNull(subscriber);
if (string.IsNullOrEmpty(subscriber.GatewaySubscriptionId))
{
logger.LogError("Cannot retrieve subscription for subscriber ({SubscriberID}) with no {FieldName}", subscriber.Id, nameof(subscriber.GatewaySubscriptionId));
return null;
}
try
{
var subscription = await stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, subscriptionGetOptions);
if (subscription != null)
{
return subscription;
}
logger.LogError("Could not find Stripe subscription ({SubscriptionID}) for subscriber ({SubscriberID})",
subscriber.GatewaySubscriptionId, subscriber.Id);
return null;
}
catch (StripeException exception)
{
logger.LogError("An error occurred while trying to retrieve Stripe subscription ({SubscriptionID}) for subscriber ({SubscriberID}): {Error}",
subscriber.GatewaySubscriptionId, subscriber.Id, exception.Message);
return null;
}
}
public async Task<Subscription> GetSubscriptionOrThrow(
ISubscriber subscriber,
SubscriptionGetOptions subscriptionGetOptions = null)
{
ArgumentNullException.ThrowIfNull(subscriber);
if (string.IsNullOrEmpty(subscriber.GatewaySubscriptionId))
{
logger.LogError("Cannot retrieve subscription for subscriber ({SubscriberID}) with no {FieldName}", subscriber.Id, nameof(subscriber.GatewaySubscriptionId));
throw ContactSupport();
}
try
{
var subscription = await stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, subscriptionGetOptions);
if (subscription != null)
{
return subscription;
}
logger.LogError("Could not find Stripe subscription ({SubscriptionID}) for subscriber ({SubscriberID})",
subscriber.GatewaySubscriptionId, subscriber.Id);
throw ContactSupport();
}
catch (StripeException exception)
{
logger.LogError("An error occurred while trying to retrieve Stripe subscription ({SubscriptionID}) for subscriber ({SubscriberID}): {Error}",
subscriber.GatewaySubscriptionId, subscriber.Id, exception.Message);
throw ContactSupport("An error occurred while trying to retrieve a Stripe Subscription", exception);
}
}
public async Task RemovePaymentMethod(
ISubscriber subscriber)
{
ArgumentNullException.ThrowIfNull(subscriber);
if (string.IsNullOrEmpty(subscriber.GatewayCustomerId))
{
throw ContactSupport();
}
var stripeCustomer = await GetCustomerOrThrow(subscriber, new CustomerGetOptions
{
Expand = ["invoice_settings.default_payment_method", "sources"]
});
if (stripeCustomer.Metadata?.TryGetValue(BraintreeCustomerIdKey, out var braintreeCustomerId) ?? false)
{
var braintreeCustomer = await braintreeGateway.Customer.FindAsync(braintreeCustomerId);
if (braintreeCustomer == null)
{
logger.LogError("Failed to retrieve Braintree customer ({ID}) when removing payment method", braintreeCustomerId);
throw ContactSupport();
}
if (braintreeCustomer.DefaultPaymentMethod != null)
{
var existingDefaultPaymentMethod = braintreeCustomer.DefaultPaymentMethod;
var updateCustomerResult = await braintreeGateway.Customer.UpdateAsync(
braintreeCustomerId,
new CustomerRequest { DefaultPaymentMethodToken = null });
if (!updateCustomerResult.IsSuccess())
{
logger.LogError("Failed to update payment method for Braintree customer ({ID}) | Message: {Message}",
braintreeCustomerId, updateCustomerResult.Message);
throw ContactSupport();
}
var deletePaymentMethodResult = await braintreeGateway.PaymentMethod.DeleteAsync(existingDefaultPaymentMethod.Token);
if (!deletePaymentMethodResult.IsSuccess())
{
await braintreeGateway.Customer.UpdateAsync(
braintreeCustomerId,
new CustomerRequest { DefaultPaymentMethodToken = existingDefaultPaymentMethod.Token });
logger.LogError(
"Failed to delete Braintree payment method for Customer ({ID}), re-linked payment method. Message: {Message}",
braintreeCustomerId, deletePaymentMethodResult.Message);
throw ContactSupport();
}
}
else
{
logger.LogWarning("Tried to remove non-existent Braintree payment method for Customer ({ID})", braintreeCustomerId);
}
}
else
{
if (stripeCustomer.Sources != null && stripeCustomer.Sources.Any())
{
foreach (var source in stripeCustomer.Sources)
{
switch (source)
{
case BankAccount:
await stripeAdapter.BankAccountDeleteAsync(stripeCustomer.Id, source.Id);
break;
case Card:
await stripeAdapter.CardDeleteAsync(stripeCustomer.Id, source.Id);
break;
}
}
}
var paymentMethods = stripeAdapter.PaymentMethodListAutoPagingAsync(new PaymentMethodListOptions
{
Customer = stripeCustomer.Id
});
await foreach (var paymentMethod in paymentMethods)
{
await stripeAdapter.PaymentMethodDetachAsync(paymentMethod.Id);
}
}
}
public async Task<TaxInfo> GetTaxInformationAsync(ISubscriber subscriber)
{
ArgumentNullException.ThrowIfNull(subscriber);
if (string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId))
{
logger.LogError("Cannot retrieve GatewayCustomerId for subscriber ({SubscriberID}) with no {FieldName}", subscriber.Id, nameof(subscriber.GatewaySubscriptionId));
return null;
}
var customer = await GetCustomerOrThrow(subscriber, new CustomerGetOptions { Expand = ["tax_ids"] });
if (customer is null)
{
logger.LogError("Could not find Stripe customer ({CustomerID}) for subscriber ({SubscriberID})",
subscriber.GatewayCustomerId, subscriber.Id);
return null;
}
var address = customer.Address;
// Line1 is required, so if missing we're using the subscriber name
// see: https://stripe.com/docs/api/customers/create#create_customer-address-line1
if (address is not null && string.IsNullOrWhiteSpace(address.Line1))
{
address.Line1 = null;
}
return MapToTaxInfo(customer);
}
public async Task<BillingInfo.BillingSource> GetPaymentMethodAsync(ISubscriber subscriber)
{
ArgumentNullException.ThrowIfNull(subscriber);
var customer = await GetCustomerOrThrow(subscriber, GetCustomerPaymentOptions());
if (customer == null)
{
logger.LogError("Could not find Stripe customer ({CustomerID}) for subscriber ({SubscriberID})",
subscriber.GatewayCustomerId, subscriber.Id);
return null;
}
if (customer.Metadata?.ContainsKey("btCustomerId") ?? false)
{
try
{
var braintreeCustomer = await braintreeGateway.Customer.FindAsync(
customer.Metadata["btCustomerId"]);
if (braintreeCustomer?.DefaultPaymentMethod != null)
{
return new BillingInfo.BillingSource(
braintreeCustomer.DefaultPaymentMethod);
}
}
catch (Braintree.Exceptions.NotFoundException ex)
{
logger.LogError("An error occurred while trying to retrieve braintree customer ({SubscriberID}): {Error}", subscriber.Id, ex.Message);
}
}
if (customer.InvoiceSettings?.DefaultPaymentMethod?.Type == "card")
{
return new BillingInfo.BillingSource(
customer.InvoiceSettings.DefaultPaymentMethod);
}
if (customer.DefaultSource != null &&
(customer.DefaultSource is Card || customer.DefaultSource is BankAccount))
{
return new BillingInfo.BillingSource(customer.DefaultSource);
}
var paymentMethod = GetLatestCardPaymentMethod(customer.Id);
return paymentMethod != null ? new BillingInfo.BillingSource(paymentMethod) : null;
}
private static CustomerGetOptions GetCustomerPaymentOptions()
{
var customerOptions = new CustomerGetOptions();
customerOptions.AddExpand("default_source");
customerOptions.AddExpand("invoice_settings.default_payment_method");
return customerOptions;
}
private Stripe.PaymentMethod GetLatestCardPaymentMethod(string customerId)
{
var cardPaymentMethods = stripeAdapter.PaymentMethodListAutoPaging(
new PaymentMethodListOptions { Customer = customerId, Type = "card" });
return cardPaymentMethods.MaxBy(m => m.Created);
}
private TaxInfo MapToTaxInfo(Customer customer)
{
var address = customer.Address;
var taxId = customer.TaxIds?.FirstOrDefault();
return new TaxInfo
{
TaxIdNumber = taxId?.Value,
BillingAddressLine1 = address?.Line1,
BillingAddressLine2 = address?.Line2,
BillingAddressCity = address?.City,
BillingAddressState = address?.State,
BillingAddressPostalCode = address?.PostalCode,
BillingAddressCountry = address?.Country,
};
}
}

View File

@ -49,7 +49,6 @@ public interface IPaymentService
Task<BillingInfo> GetBillingHistoryAsync(ISubscriber subscriber);
Task<BillingInfo> GetBillingBalanceAndSourceAsync(ISubscriber subscriber);
Task<SubscriptionInfo> GetSubscriptionAsync(ISubscriber subscriber);
Task<TaxInfo> GetTaxInfoAsync(ISubscriber subscriber);
Task SaveTaxInfoAsync(ISubscriber subscriber, TaxInfo taxInfo);
Task<TaxRate> CreateTaxRateAsync(TaxRate taxRate);
Task UpdateTaxRateAsync(TaxRate taxRate);

View File

@ -1651,43 +1651,6 @@ public class StripePaymentService : IPaymentService
return subscriptionInfo;
}
public async Task<TaxInfo> GetTaxInfoAsync(ISubscriber subscriber)
{
if (subscriber == null || string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId))
{
return null;
}
var customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId,
new CustomerGetOptions { Expand = ["tax_ids"] });
if (customer == null)
{
return null;
}
var address = customer.Address;
var taxId = customer.TaxIds?.FirstOrDefault();
// Line1 is required, so if missing we're using the subscriber name
// see: https://stripe.com/docs/api/customers/create#create_customer-address-line1
if (address != null && string.IsNullOrWhiteSpace(address.Line1))
{
address.Line1 = null;
}
return new TaxInfo
{
TaxIdNumber = taxId?.Value,
BillingAddressLine1 = address?.Line1,
BillingAddressLine2 = address?.Line2,
BillingAddressCity = address?.City,
BillingAddressState = address?.State,
BillingAddressPostalCode = address?.PostalCode,
BillingAddressCountry = address?.Country,
};
}
public async Task SaveTaxInfoAsync(ISubscriber subscriber, TaxInfo taxInfo)
{
if (subscriber != null && !string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId))