mirror of
https://github.com/bitwarden/server.git
synced 2024-11-21 12:05:42 +01: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:
parent
a9ab894893
commit
06910175e2
@ -4,15 +4,14 @@ using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Providers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Commands;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Commercial.Core.AdminConsole.Providers;
|
||||
@ -20,35 +19,35 @@ namespace Bit.Commercial.Core.AdminConsole.Providers;
|
||||
public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProviderCommand
|
||||
{
|
||||
private readonly IEventService _eventService;
|
||||
private readonly ILogger<RemoveOrganizationFromProviderCommand> _logger;
|
||||
private readonly IMailService _mailService;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly IProviderOrganizationRepository _providerOrganizationRepository;
|
||||
private readonly IStripeAdapter _stripeAdapter;
|
||||
private readonly IScaleSeatsCommand _scaleSeatsCommand;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IProviderBillingService _providerBillingService;
|
||||
private readonly ISubscriberService _subscriberService;
|
||||
|
||||
public RemoveOrganizationFromProviderCommand(
|
||||
IEventService eventService,
|
||||
ILogger<RemoveOrganizationFromProviderCommand> logger,
|
||||
IMailService mailService,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationService organizationService,
|
||||
IProviderOrganizationRepository providerOrganizationRepository,
|
||||
IStripeAdapter stripeAdapter,
|
||||
IScaleSeatsCommand scaleSeatsCommand,
|
||||
IFeatureService featureService)
|
||||
IFeatureService featureService,
|
||||
IProviderBillingService providerBillingService,
|
||||
ISubscriberService subscriberService)
|
||||
{
|
||||
_eventService = eventService;
|
||||
_logger = logger;
|
||||
_mailService = mailService;
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationService = organizationService;
|
||||
_providerOrganizationRepository = providerOrganizationRepository;
|
||||
_stripeAdapter = stripeAdapter;
|
||||
_scaleSeatsCommand = scaleSeatsCommand;
|
||||
_featureService = featureService;
|
||||
_providerBillingService = providerBillingService;
|
||||
_subscriberService = subscriberService;
|
||||
}
|
||||
|
||||
public async Task RemoveOrganizationFromProvider(
|
||||
@ -99,23 +98,19 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
|
||||
Provider provider,
|
||||
IEnumerable<string> organizationOwnerEmails)
|
||||
{
|
||||
if (!organization.IsStripeEnabled())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||
|
||||
var customerUpdateOptions = new CustomerUpdateOptions
|
||||
if (isConsolidatedBillingEnabled &&
|
||||
provider.Status == ProviderStatusType.Billable &&
|
||||
organization.Status == OrganizationStatusType.Managed &&
|
||||
!string.IsNullOrEmpty(organization.GatewayCustomerId))
|
||||
{
|
||||
Coupon = string.Empty,
|
||||
await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
|
||||
{
|
||||
Description = string.Empty,
|
||||
Email = organization.BillingEmail
|
||||
};
|
||||
});
|
||||
|
||||
await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, customerUpdateOptions);
|
||||
|
||||
if (isConsolidatedBillingEnabled && provider.Status == ProviderStatusType.Billable)
|
||||
{
|
||||
var plan = StaticStore.GetPlan(organization.PlanType).PasswordManager;
|
||||
|
||||
var subscriptionCreateOptions = new SubscriptionCreateOptions
|
||||
@ -136,19 +131,25 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
|
||||
var subscription = await _stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
|
||||
|
||||
organization.GatewaySubscriptionId = subscription.Id;
|
||||
organization.Status = OrganizationStatusType.Created;
|
||||
|
||||
await _scaleSeatsCommand.ScalePasswordManagerSeats(provider, organization.PlanType,
|
||||
-(organization.Seats ?? 0));
|
||||
await _providerBillingService.ScaleSeats(provider, organization.PlanType, -organization.Seats ?? 0);
|
||||
}
|
||||
else
|
||||
else if (organization.IsStripeEnabled())
|
||||
{
|
||||
var subscriptionUpdateOptions = new SubscriptionUpdateOptions
|
||||
await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
|
||||
{
|
||||
CollectionMethod = "send_invoice",
|
||||
DaysUntilDue = 30
|
||||
};
|
||||
Coupon = string.Empty,
|
||||
Email = organization.BillingEmail
|
||||
});
|
||||
|
||||
await _stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, subscriptionUpdateOptions);
|
||||
await _stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, new SubscriptionUpdateOptions
|
||||
{
|
||||
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
|
||||
DaysUntilDue = 30
|
||||
});
|
||||
|
||||
await _subscriberService.RemovePaymentMethod(organization);
|
||||
}
|
||||
|
||||
await _mailService.SendProviderUpdatePaymentMethod(
|
||||
|
@ -0,0 +1,512 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Entities;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Repositories;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Repositories;
|
||||
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.Commercial.Core.Billing;
|
||||
|
||||
public class ProviderBillingService(
|
||||
IGlobalSettings globalSettings,
|
||||
ILogger<ProviderBillingService> logger,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IPaymentService paymentService,
|
||||
IProviderOrganizationRepository providerOrganizationRepository,
|
||||
IProviderPlanRepository providerPlanRepository,
|
||||
IProviderRepository providerRepository,
|
||||
IStripeAdapter stripeAdapter,
|
||||
ISubscriberService subscriberService) : IProviderBillingService
|
||||
{
|
||||
public async Task AssignSeatsToClientOrganization(
|
||||
Provider provider,
|
||||
Organization organization,
|
||||
int seats)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(organization);
|
||||
|
||||
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 to it", organization.Id, organization.Seats);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var seatAdjustment = seats - (organization.Seats ?? 0);
|
||||
|
||||
await ScaleSeats(provider, organization.PlanType, seatAdjustment);
|
||||
|
||||
organization.Seats = seats;
|
||||
|
||||
await organizationRepository.ReplaceAsync(organization);
|
||||
}
|
||||
|
||||
public async Task CreateCustomer(
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(provider);
|
||||
ArgumentNullException.ThrowIfNull(taxInfo);
|
||||
|
||||
if (string.IsNullOrEmpty(taxInfo.BillingAddressCountry) ||
|
||||
string.IsNullOrEmpty(taxInfo.BillingAddressPostalCode))
|
||||
{
|
||||
logger.LogError("Cannot create Stripe customer for provider ({ID}) - Both the provider's country and postal code are required", provider.Id);
|
||||
|
||||
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,
|
||||
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 customer = await stripeAdapter.CustomerCreateAsync(customerCreateOptions);
|
||||
|
||||
provider.GatewayCustomerId = customer.Id;
|
||||
|
||||
await providerRepository.ReplaceAsync(provider);
|
||||
}
|
||||
|
||||
public async Task CreateCustomerForClientOrganization(
|
||||
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 subscriberService.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);
|
||||
}
|
||||
|
||||
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 subscriberService.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);
|
||||
}
|
||||
|
||||
public async Task ScaleSeats(
|
||||
Provider provider,
|
||||
PlanType planType,
|
||||
int seatAdjustment)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(provider);
|
||||
|
||||
if (provider.Type != ProviderType.Msp)
|
||||
{
|
||||
logger.LogError("Non-MSP provider ({ProviderID}) cannot scale their seats", provider.Id);
|
||||
|
||||
throw ContactSupport();
|
||||
}
|
||||
|
||||
if (!planType.SupportsConsolidatedBilling())
|
||||
{
|
||||
logger.LogError("Cannot scale provider ({ProviderID}) 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}) 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 GetAssignedSeatTotalForPlanOrThrow(provider.Id, planType);
|
||||
|
||||
var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment;
|
||||
|
||||
var update = CurrySeatScalingUpdate(
|
||||
provider,
|
||||
providerPlan,
|
||||
newlyAssignedSeatTotal);
|
||||
|
||||
/*
|
||||
* Below the limit => Below the limit:
|
||||
* No subscription update required. We can safely update the provider's allocated 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);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StartSubscription(
|
||||
Provider provider)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(provider);
|
||||
|
||||
if (!string.IsNullOrEmpty(provider.GatewaySubscriptionId))
|
||||
{
|
||||
logger.LogWarning("Cannot start Provider subscription - Provider ({ID}) already has a {FieldName}", provider.Id, nameof(provider.GatewaySubscriptionId));
|
||||
|
||||
throw ContactSupport();
|
||||
}
|
||||
|
||||
var customer = await subscriberService.GetCustomerOrThrow(provider);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public async Task<ProviderPaymentInfoDTO> GetPaymentInformationAsync(Guid providerId)
|
||||
{
|
||||
var provider = await providerRepository.GetByIdAsync(providerId);
|
||||
|
||||
if (provider == null)
|
||||
{
|
||||
logger.LogError(
|
||||
"Could not find provider ({ID}) when retrieving payment information.",
|
||||
providerId);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (provider.Type == ProviderType.Reseller)
|
||||
{
|
||||
logger.LogError("payment information cannot be retrieved for reseller-type provider ({ID})", providerId);
|
||||
|
||||
throw ContactSupport("Consolidated billing does not support reseller-type providers");
|
||||
}
|
||||
|
||||
var taxInformation = await subscriberService.GetTaxInformationAsync(provider);
|
||||
var billingInformation = await subscriberService.GetPaymentMethodAsync(provider);
|
||||
|
||||
if (taxInformation == null && billingInformation == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ProviderPaymentInfoDTO(
|
||||
billingInformation,
|
||||
taxInformation);
|
||||
}
|
||||
|
||||
private Func<int, int, Task> CurrySeatScalingUpdate(
|
||||
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);
|
||||
};
|
||||
}
|
@ -1,7 +1,9 @@
|
||||
using Bit.Commercial.Core.AdminConsole.Providers;
|
||||
using Bit.Commercial.Core.AdminConsole.Services;
|
||||
using Bit.Commercial.Core.Billing;
|
||||
using Bit.Core.AdminConsole.Providers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bit.Commercial.Core.Utilities;
|
||||
@ -13,5 +15,6 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<IProviderService, ProviderService>();
|
||||
services.AddScoped<ICreateProviderCommand, CreateProviderCommand>();
|
||||
services.AddScoped<IRemoveOrganizationFromProviderCommand, RemoveOrganizationFromProviderCommand>();
|
||||
services.AddTransient<IProviderBillingService, ProviderBillingService>();
|
||||
}
|
||||
}
|
||||
|
@ -4,17 +4,18 @@ using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Commands;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
using IMailService = Bit.Core.Services.IMailService;
|
||||
|
||||
namespace Bit.Commercial.Core.Test.AdminConsole.ProviderFeatures;
|
||||
|
||||
@ -75,7 +76,7 @@ public class RemoveOrganizationFromProviderCommandTests
|
||||
|
||||
sutProvider.GetDependency<IOrganizationService>().HasConfirmedOwnersExceptAsync(
|
||||
providerOrganization.OrganizationId,
|
||||
Array.Empty<Guid>(),
|
||||
[],
|
||||
includeProvider: false)
|
||||
.Returns(false);
|
||||
|
||||
@ -85,56 +86,53 @@ public class RemoveOrganizationFromProviderCommandTests
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemoveOrganizationFromProvider_NoStripeObjects_MakesCorrectInvocations(
|
||||
public async Task RemoveOrganizationFromProvider_OrganizationNotStripeEnabled_MakesCorrectInvocations(
|
||||
Provider provider,
|
||||
ProviderOrganization providerOrganization,
|
||||
Organization organization,
|
||||
SutProvider<RemoveOrganizationFromProviderCommand> sutProvider)
|
||||
{
|
||||
providerOrganization.ProviderId = provider.Id;
|
||||
|
||||
organization.GatewayCustomerId = null;
|
||||
organization.GatewaySubscriptionId = null;
|
||||
|
||||
providerOrganization.ProviderId = provider.Id;
|
||||
|
||||
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||
|
||||
sutProvider.GetDependency<IOrganizationService>().HasConfirmedOwnersExceptAsync(
|
||||
providerOrganization.OrganizationId,
|
||||
Array.Empty<Guid>(),
|
||||
[],
|
||||
includeProvider: false)
|
||||
.Returns(true);
|
||||
|
||||
var organizationOwnerEmails = new List<string> { "a@gmail.com", "b@gmail.com" };
|
||||
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||
|
||||
organizationRepository.GetOwnerEmailAddressesById(organization.Id).Returns(organizationOwnerEmails);
|
||||
organizationRepository.GetOwnerEmailAddressesById(organization.Id).Returns([
|
||||
"a@example.com",
|
||||
"b@example.com"
|
||||
]);
|
||||
|
||||
await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
|
||||
|
||||
await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(
|
||||
org => org.Id == organization.Id && org.BillingEmail == "a@gmail.com"));
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
await stripeAdapter.DidNotReceiveWithAnyArgs().CustomerUpdateAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>());
|
||||
|
||||
await stripeAdapter.DidNotReceiveWithAnyArgs().SubscriptionUpdateAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
|
||||
|
||||
await sutProvider.GetDependency<IMailService>().DidNotReceiveWithAnyArgs().SendProviderUpdatePaymentMethod(
|
||||
Arg.Any<Guid>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<IEnumerable<string>>());
|
||||
await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(org => org.BillingEmail == "a@example.com"));
|
||||
|
||||
await sutProvider.GetDependency<IProviderOrganizationRepository>().Received(1)
|
||||
.DeleteAsync(providerOrganization);
|
||||
|
||||
await sutProvider.GetDependency<IEventService>().Received(1).LogProviderOrganizationEventAsync(
|
||||
providerOrganization,
|
||||
EventType.ProviderOrganization_Removed);
|
||||
await sutProvider.GetDependency<IEventService>().Received(1)
|
||||
.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Removed);
|
||||
|
||||
await sutProvider.GetDependency<IMailService>().Received(1)
|
||||
.SendProviderUpdatePaymentMethod(
|
||||
organization.Id,
|
||||
organization.Name,
|
||||
provider.Name,
|
||||
Arg.Is<IEnumerable<string>>(emails => emails.FirstOrDefault() == "a@example.com"));
|
||||
|
||||
await sutProvider.GetDependency<IStripeAdapter>().DidNotReceiveWithAnyArgs()
|
||||
.CustomerUpdateAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemoveOrganizationFromProvider_MakesCorrectInvocations__FeatureFlagOff(
|
||||
public async Task RemoveOrganizationFromProvider_OrganizationStripeEnabled_NonConsolidatedBilling_MakesCorrectInvocations(
|
||||
Provider provider,
|
||||
ProviderOrganization providerOrganization,
|
||||
Organization organization,
|
||||
@ -142,104 +140,126 @@ public class RemoveOrganizationFromProviderCommandTests
|
||||
{
|
||||
providerOrganization.ProviderId = provider.Id;
|
||||
|
||||
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||
|
||||
sutProvider.GetDependency<IOrganizationService>().HasConfirmedOwnersExceptAsync(
|
||||
providerOrganization.OrganizationId,
|
||||
Array.Empty<Guid>(),
|
||||
[],
|
||||
includeProvider: false)
|
||||
.Returns(true);
|
||||
|
||||
var organizationOwnerEmails = new List<string> { "a@example.com", "b@example.com" };
|
||||
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||
|
||||
organizationRepository.GetOwnerEmailAddressesById(organization.Id).Returns(organizationOwnerEmails);
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription
|
||||
{
|
||||
Id = "S-1",
|
||||
CurrentPeriodEnd = DateTime.Today.AddDays(10),
|
||||
});
|
||||
organizationRepository.GetOwnerEmailAddressesById(organization.Id).Returns([
|
||||
"a@example.com",
|
||||
"b@example.com"
|
||||
]);
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
|
||||
.Returns(false);
|
||||
|
||||
await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
|
||||
|
||||
await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(
|
||||
org => org.Id == organization.Id && org.BillingEmail == "a@example.com"));
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
await stripeAdapter.Received(1).CustomerUpdateAsync(
|
||||
organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(
|
||||
options => options.Coupon == string.Empty && options.Email == "a@example.com"));
|
||||
await stripeAdapter.Received(1).CustomerUpdateAsync(organization.GatewayCustomerId,
|
||||
Arg.Is<CustomerUpdateOptions>(options =>
|
||||
options.Coupon == string.Empty && options.Email == "a@example.com"));
|
||||
|
||||
await sutProvider.GetDependency<IMailService>().Received(1).SendProviderUpdatePaymentMethod(
|
||||
organization.Id,
|
||||
organization.Name,
|
||||
provider.Name,
|
||||
Arg.Is<IEnumerable<string>>(emails => emails.Contains("a@example.com") && emails.Contains("b@example.com")));
|
||||
await stripeAdapter.Received(1).SubscriptionUpdateAsync(organization.GatewaySubscriptionId,
|
||||
Arg.Is<SubscriptionUpdateOptions>(options =>
|
||||
options.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice &&
|
||||
options.DaysUntilDue == 30));
|
||||
|
||||
await sutProvider.GetDependency<ISubscriberService>().Received(1).RemovePaymentMethod(organization);
|
||||
|
||||
await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(org => org.BillingEmail == "a@example.com"));
|
||||
|
||||
await sutProvider.GetDependency<IProviderOrganizationRepository>().Received(1)
|
||||
.DeleteAsync(providerOrganization);
|
||||
|
||||
await sutProvider.GetDependency<IEventService>().Received(1).LogProviderOrganizationEventAsync(
|
||||
providerOrganization,
|
||||
EventType.ProviderOrganization_Removed);
|
||||
await sutProvider.GetDependency<IEventService>().Received(1)
|
||||
.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Removed);
|
||||
|
||||
await sutProvider.GetDependency<IMailService>().Received(1)
|
||||
.SendProviderUpdatePaymentMethod(
|
||||
organization.Id,
|
||||
organization.Name,
|
||||
provider.Name,
|
||||
Arg.Is<IEnumerable<string>>(emails => emails.FirstOrDefault() == "a@example.com"));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemoveOrganizationFromProvider_CreatesSubscriptionAndScalesSeats_FeatureFlagON(Provider provider,
|
||||
public async Task RemoveOrganizationFromProvider_OrganizationStripeEnabled_ConsolidatedBilling_MakesCorrectInvocations(
|
||||
Provider provider,
|
||||
ProviderOrganization providerOrganization,
|
||||
Organization organization,
|
||||
SutProvider<RemoveOrganizationFromProviderCommand> sutProvider)
|
||||
{
|
||||
providerOrganization.ProviderId = provider.Id;
|
||||
provider.Status = ProviderStatusType.Billable;
|
||||
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||
|
||||
providerOrganization.ProviderId = provider.Id;
|
||||
|
||||
organization.Status = OrganizationStatusType.Managed;
|
||||
|
||||
organization.PlanType = PlanType.TeamsMonthly;
|
||||
|
||||
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationService>().HasConfirmedOwnersExceptAsync(
|
||||
providerOrganization.OrganizationId,
|
||||
Array.Empty<Guid>(),
|
||||
[],
|
||||
includeProvider: false)
|
||||
.Returns(true);
|
||||
|
||||
var organizationOwnerEmails = new List<string> { "a@example.com", "b@example.com" };
|
||||
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||
|
||||
organizationRepository.GetOwnerEmailAddressesById(organization.Id).Returns(organizationOwnerEmails);
|
||||
organizationRepository.GetOwnerEmailAddressesById(organization.Id).Returns([
|
||||
"a@example.com",
|
||||
"b@example.com"
|
||||
]);
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
|
||||
.Returns(true);
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription
|
||||
|
||||
stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(new Subscription
|
||||
{
|
||||
Id = "S-1",
|
||||
CurrentPeriodEnd = DateTime.Today.AddDays(10),
|
||||
Id = "subscription_id"
|
||||
});
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
|
||||
|
||||
await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
|
||||
await stripeAdapter.Received(1).CustomerUpdateAsync(
|
||||
organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(
|
||||
options => options.Coupon == string.Empty && options.Email == "a@example.com"));
|
||||
|
||||
await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(c =>
|
||||
c.Customer == organization.GatewayCustomerId &&
|
||||
c.CollectionMethod == "send_invoice" &&
|
||||
c.DaysUntilDue == 30 &&
|
||||
c.Items.Count == 1
|
||||
));
|
||||
await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(options =>
|
||||
options.Customer == organization.GatewayCustomerId &&
|
||||
options.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice &&
|
||||
options.DaysUntilDue == 30 &&
|
||||
options.AutomaticTax.Enabled == true &&
|
||||
options.Metadata["organizationId"] == organization.Id.ToString() &&
|
||||
options.OffSession == true &&
|
||||
options.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations &&
|
||||
options.Items.First().Price == teamsMonthlyPlan.PasswordManager.StripeSeatPlanId &&
|
||||
options.Items.First().Quantity == organization.Seats));
|
||||
|
||||
await sutProvider.GetDependency<IScaleSeatsCommand>().Received(1)
|
||||
.ScalePasswordManagerSeats(provider, organization.PlanType, -(int)organization.Seats);
|
||||
await sutProvider.GetDependency<IProviderBillingService>().Received(1)
|
||||
.ScaleSeats(provider, organization.PlanType, -organization.Seats ?? 0);
|
||||
|
||||
await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(
|
||||
org => org.Id == organization.Id && org.BillingEmail == "a@example.com" &&
|
||||
org.GatewaySubscriptionId == "S-1"));
|
||||
|
||||
await sutProvider.GetDependency<IMailService>().Received(1).SendProviderUpdatePaymentMethod(
|
||||
organization.Id,
|
||||
organization.Name,
|
||||
provider.Name,
|
||||
Arg.Is<IEnumerable<string>>(emails =>
|
||||
emails.Contains("a@example.com") && emails.Contains("b@example.com")));
|
||||
org =>
|
||||
org.BillingEmail == "a@example.com" &&
|
||||
org.GatewaySubscriptionId == "subscription_id" &&
|
||||
org.Status == OrganizationStatusType.Created));
|
||||
|
||||
await sutProvider.GetDependency<IProviderOrganizationRepository>().Received(1)
|
||||
.DeleteAsync(providerOrganization);
|
||||
|
||||
await sutProvider.GetDependency<IEventService>().Received(1).LogProviderOrganizationEventAsync(
|
||||
providerOrganization,
|
||||
EventType.ProviderOrganization_Removed);
|
||||
await sutProvider.GetDependency<IEventService>().Received(1)
|
||||
.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Removed);
|
||||
|
||||
await sutProvider.GetDependency<IMailService>().Received(1)
|
||||
.SendProviderUpdatePaymentMethod(
|
||||
organization.Id,
|
||||
organization.Name,
|
||||
provider.Name,
|
||||
Arg.Is<IEnumerable<string>>(emails => emails.FirstOrDefault() == "a@example.com"));
|
||||
}
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
{
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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);
|
@ -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);
|
||||
}
|
@ -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);
|
||||
}
|
@ -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);
|
||||
}
|
@ -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);
|
||||
}
|
@ -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);
|
||||
}
|
@ -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);
|
||||
}
|
@ -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);
|
||||
};
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
};
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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>();
|
||||
}
|
||||
}
|
||||
|
6
src/Core/Billing/Models/ProviderPaymentInfoDTO.cs
Normal file
6
src/Core/Billing/Models/ProviderPaymentInfoDTO.cs
Normal file
@ -0,0 +1,6 @@
|
||||
using Bit.Core.Models.Business;
|
||||
|
||||
namespace Bit.Core.Billing.Models;
|
||||
|
||||
public record ProviderPaymentInfoDTO(BillingInfo.BillingSource billingSource,
|
||||
TaxInfo taxInfo);
|
@ -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);
|
||||
}
|
@ -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);
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
96
src/Core/Billing/Services/IProviderBillingService.cs
Normal file
96
src/Core/Billing/Services/IProviderBillingService.cs
Normal 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);
|
||||
}
|
100
src/Core/Billing/Services/ISubscriberService.cs
Normal file
100
src/Core/Billing/Services/ISubscriberService.cs
Normal 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);
|
||||
}
|
@ -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)
|
||||
{
|
444
src/Core/Billing/Services/Implementations/SubscriberService.cs
Normal file
444
src/Core/Billing/Services/Implementations/SubscriberService.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
@ -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);
|
||||
|
@ -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))
|
||||
|
@ -14,7 +14,7 @@ using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Auth.Repositories;
|
||||
using Bit.Core.Auth.Services;
|
||||
using Bit.Core.Billing.Commands;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
@ -48,7 +48,7 @@ public class OrganizationsControllerTests : IDisposable
|
||||
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;
|
||||
|
||||
private readonly OrganizationsController _sut;
|
||||
@ -72,7 +72,7 @@ public class OrganizationsControllerTests : IDisposable
|
||||
_pushNotificationService = Substitute.For<IPushNotificationService>();
|
||||
_organizationEnableCollectionEnhancementsCommand = Substitute.For<IOrganizationEnableCollectionEnhancementsCommand>();
|
||||
_providerRepository = Substitute.For<IProviderRepository>();
|
||||
_scaleSeatsCommand = Substitute.For<IScaleSeatsCommand>();
|
||||
_providerBillingService = Substitute.For<IProviderBillingService>();
|
||||
_orgDeleteTokenDataFactory = Substitute.For<IDataProtectorTokenFactory<OrgDeleteTokenable>>();
|
||||
|
||||
_sut = new OrganizationsController(
|
||||
@ -93,7 +93,7 @@ public class OrganizationsControllerTests : IDisposable
|
||||
_pushNotificationService,
|
||||
_organizationEnableCollectionEnhancementsCommand,
|
||||
_providerRepository,
|
||||
_scaleSeatsCommand,
|
||||
_providerBillingService,
|
||||
_orgDeleteTokenDataFactory);
|
||||
}
|
||||
|
||||
@ -233,8 +233,8 @@ public class OrganizationsControllerTests : IDisposable
|
||||
|
||||
await _sut.Delete(organizationId.ToString(), requestModel);
|
||||
|
||||
await _scaleSeatsCommand.Received(1)
|
||||
.ScalePasswordManagerSeats(provider, organization.PlanType, -organization.Seats.Value);
|
||||
await _providerBillingService.Received(1)
|
||||
.ScaleSeats(provider, organization.PlanType, -organization.Seats.Value);
|
||||
|
||||
await _organizationService.Received(1).DeleteAsync(organization);
|
||||
}
|
||||
|
@ -14,8 +14,7 @@ using Bit.Core.Auth.Models.Api.Request.Accounts;
|
||||
using Bit.Core.Auth.Services;
|
||||
using Bit.Core.Auth.UserFeatures.UserKey;
|
||||
using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
|
||||
using Bit.Core.Billing.Commands;
|
||||
using Bit.Core.Billing.Queries;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
@ -56,8 +55,7 @@ public class AccountsControllerTests : IDisposable
|
||||
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;
|
||||
|
||||
@ -89,8 +87,7 @@ public class AccountsControllerTests : IDisposable
|
||||
_setInitialMasterPasswordCommand = Substitute.For<ISetInitialMasterPasswordCommand>();
|
||||
_rotateUserKeyCommand = Substitute.For<IRotateUserKeyCommand>();
|
||||
_featureService = Substitute.For<IFeatureService>();
|
||||
_cancelSubscriptionCommand = Substitute.For<ICancelSubscriptionCommand>();
|
||||
_subscriberQueries = Substitute.For<ISubscriberQueries>();
|
||||
_subscriberService = Substitute.For<ISubscriberService>();
|
||||
_referenceEventService = Substitute.For<IReferenceEventService>();
|
||||
_currentContext = Substitute.For<ICurrentContext>();
|
||||
_cipherValidator =
|
||||
@ -121,8 +118,7 @@ public class AccountsControllerTests : IDisposable
|
||||
_setInitialMasterPasswordCommand,
|
||||
_rotateUserKeyCommand,
|
||||
_featureService,
|
||||
_cancelSubscriptionCommand,
|
||||
_subscriberQueries,
|
||||
_subscriberService,
|
||||
_referenceEventService,
|
||||
_currentContext,
|
||||
_cipherValidator,
|
||||
|
@ -1,7 +1,7 @@
|
||||
using Bit.Api.Billing.Controllers;
|
||||
using Bit.Api.Billing.Models.Responses;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Queries;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
@ -29,7 +29,7 @@ public class OrganizationBillingControllerTests
|
||||
Guid organizationId,
|
||||
SutProvider<OrganizationBillingController> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IOrganizationBillingQueries>().GetMetadata(organizationId)
|
||||
sutProvider.GetDependency<IOrganizationBillingService>().GetMetadata(organizationId)
|
||||
.Returns(new OrganizationMetadataDTO(true));
|
||||
|
||||
var result = await sutProvider.Sut.GetMetadataAsync(organizationId);
|
||||
|
@ -10,8 +10,7 @@ using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Auth.Repositories;
|
||||
using Bit.Core.Auth.Services;
|
||||
using Bit.Core.Billing.Commands;
|
||||
using Bit.Core.Billing.Queries;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
@ -45,9 +44,8 @@ public class OrganizationsControllerTests : IDisposable
|
||||
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
|
||||
private readonly IUpgradeOrganizationPlanCommand _upgradeOrganizationPlanCommand;
|
||||
private readonly IAddSecretsManagerSubscriptionCommand _addSecretsManagerSubscriptionCommand;
|
||||
private readonly ICancelSubscriptionCommand _cancelSubscriptionCommand;
|
||||
private readonly ISubscriberQueries _subscriberQueries;
|
||||
private readonly IReferenceEventService _referenceEventService;
|
||||
private readonly ISubscriberService _subscriberService;
|
||||
|
||||
private readonly OrganizationsController _sut;
|
||||
|
||||
@ -68,9 +66,8 @@ public class OrganizationsControllerTests : IDisposable
|
||||
_updateSecretsManagerSubscriptionCommand = Substitute.For<IUpdateSecretsManagerSubscriptionCommand>();
|
||||
_upgradeOrganizationPlanCommand = Substitute.For<IUpgradeOrganizationPlanCommand>();
|
||||
_addSecretsManagerSubscriptionCommand = Substitute.For<IAddSecretsManagerSubscriptionCommand>();
|
||||
_cancelSubscriptionCommand = Substitute.For<ICancelSubscriptionCommand>();
|
||||
_subscriberQueries = Substitute.For<ISubscriberQueries>();
|
||||
_referenceEventService = Substitute.For<IReferenceEventService>();
|
||||
_subscriberService = Substitute.For<ISubscriberService>();
|
||||
|
||||
_sut = new OrganizationsController(
|
||||
_organizationRepository,
|
||||
@ -85,9 +82,8 @@ public class OrganizationsControllerTests : IDisposable
|
||||
_updateSecretsManagerSubscriptionCommand,
|
||||
_upgradeOrganizationPlanCommand,
|
||||
_addSecretsManagerSubscriptionCommand,
|
||||
_cancelSubscriptionCommand,
|
||||
_subscriberQueries,
|
||||
_referenceEventService);
|
||||
_referenceEventService,
|
||||
_subscriberService);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
@ -2,7 +2,7 @@
|
||||
using Bit.Api.Billing.Models.Responses;
|
||||
using Bit.Core;
|
||||
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.Services;
|
||||
@ -61,7 +61,7 @@ public class ProviderBillingControllerTests
|
||||
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(providerId)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IProviderBillingQueries>().GetSubscriptionDTO(providerId).ReturnsNull();
|
||||
sutProvider.GetDependency<IProviderBillingService>().GetSubscriptionDTO(providerId).ReturnsNull();
|
||||
|
||||
var result = await sutProvider.Sut.GetSubscriptionAsync(providerId);
|
||||
|
||||
@ -96,7 +96,7 @@ public class ProviderBillingControllerTests
|
||||
configuredProviderPlanDTOList,
|
||||
subscription);
|
||||
|
||||
sutProvider.GetDependency<IProviderBillingQueries>().GetSubscriptionDTO(providerId)
|
||||
sutProvider.GetDependency<IProviderBillingService>().GetSubscriptionDTO(providerId)
|
||||
.Returns(providerSubscriptionDTO);
|
||||
|
||||
var result = await sutProvider.Sut.GetSubscriptionAsync(providerId);
|
||||
|
@ -6,7 +6,7 @@ using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
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.Entities;
|
||||
using Bit.Core.Models.Business;
|
||||
@ -185,7 +185,7 @@ public class ProviderClientsControllerTests
|
||||
|
||||
Assert.IsType<Ok>(result);
|
||||
|
||||
await sutProvider.GetDependency<ICreateCustomerCommand>().Received(1).CreateCustomer(
|
||||
await sutProvider.GetDependency<IProviderBillingService>().Received(1).CreateCustomerForClientOrganization(
|
||||
provider,
|
||||
clientOrganization);
|
||||
}
|
||||
@ -327,7 +327,7 @@ public class ProviderClientsControllerTests
|
||||
|
||||
var result = await sutProvider.Sut.UpdateAsync(providerId, providerOrganizationId, requestBody);
|
||||
|
||||
await sutProvider.GetDependency<IAssignSeatsToClientOrganizationCommand>().Received(1)
|
||||
await sutProvider.GetDependency<IProviderBillingService>().Received(1)
|
||||
.AssignSeatsToClientOrganization(
|
||||
provider,
|
||||
organization,
|
||||
@ -368,7 +368,7 @@ public class ProviderClientsControllerTests
|
||||
|
||||
var result = await sutProvider.Sut.UpdateAsync(providerId, providerOrganizationId, requestBody);
|
||||
|
||||
await sutProvider.GetDependency<IAssignSeatsToClientOrganizationCommand>().DidNotReceiveWithAnyArgs()
|
||||
await sutProvider.GetDependency<IProviderBillingService>().DidNotReceiveWithAnyArgs()
|
||||
.AssignSeatsToClientOrganization(
|
||||
Arg.Any<Provider>(),
|
||||
Arg.Any<Organization>(),
|
||||
|
@ -1,339 +0,0 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.Billing;
|
||||
using Bit.Core.Billing.Commands.Implementations;
|
||||
using Bit.Core.Billing.Entities;
|
||||
using Bit.Core.Billing.Queries;
|
||||
using Bit.Core.Billing.Repositories;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.StaticStore;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
using static Bit.Core.Test.Billing.Utilities;
|
||||
|
||||
namespace Bit.Core.Test.Billing.Commands;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class AssignSeatsToClientOrganizationCommandTests
|
||||
{
|
||||
[Theory, BitAutoData]
|
||||
public Task AssignSeatsToClientOrganization_NullProvider_ArgumentNullException(
|
||||
Organization organization,
|
||||
int seats,
|
||||
SutProvider<AssignSeatsToClientOrganizationCommand> sutProvider)
|
||||
=> Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
sutProvider.Sut.AssignSeatsToClientOrganization(null, organization, seats));
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public Task AssignSeatsToClientOrganization_NullOrganization_ArgumentNullException(
|
||||
Provider provider,
|
||||
int seats,
|
||||
SutProvider<AssignSeatsToClientOrganizationCommand> sutProvider)
|
||||
=> Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
sutProvider.Sut.AssignSeatsToClientOrganization(provider, null, seats));
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public Task AssignSeatsToClientOrganization_NegativeSeats_BillingException(
|
||||
Provider provider,
|
||||
Organization organization,
|
||||
SutProvider<AssignSeatsToClientOrganizationCommand> sutProvider)
|
||||
=> Assert.ThrowsAsync<BillingException>(() =>
|
||||
sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, -5));
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task AssignSeatsToClientOrganization_CurrentSeatsMatchesNewSeats_NoOp(
|
||||
Provider provider,
|
||||
Organization organization,
|
||||
int seats,
|
||||
SutProvider<AssignSeatsToClientOrganizationCommand> sutProvider)
|
||||
{
|
||||
organization.PlanType = PlanType.TeamsMonthly;
|
||||
|
||||
organization.Seats = seats;
|
||||
|
||||
await sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats);
|
||||
|
||||
await sutProvider.GetDependency<IProviderPlanRepository>().DidNotReceive().GetByProviderId(provider.Id);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task AssignSeatsToClientOrganization_OrganizationPlanTypeDoesNotSupportConsolidatedBilling_ContactSupport(
|
||||
Provider provider,
|
||||
Organization organization,
|
||||
int seats,
|
||||
SutProvider<AssignSeatsToClientOrganizationCommand> sutProvider)
|
||||
{
|
||||
organization.PlanType = PlanType.FamiliesAnnually;
|
||||
|
||||
await ThrowsContactSupportAsync(() => sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task AssignSeatsToClientOrganization_ProviderPlanIsNotConfigured_ContactSupport(
|
||||
Provider provider,
|
||||
Organization organization,
|
||||
int seats,
|
||||
SutProvider<AssignSeatsToClientOrganizationCommand> sutProvider)
|
||||
{
|
||||
organization.PlanType = PlanType.TeamsMonthly;
|
||||
|
||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(new List<ProviderPlan>
|
||||
{
|
||||
new ()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PlanType = PlanType.TeamsMonthly,
|
||||
ProviderId = provider.Id
|
||||
}
|
||||
});
|
||||
|
||||
await ThrowsContactSupportAsync(() => sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task AssignSeatsToClientOrganization_BelowToBelow_Succeeds(
|
||||
Provider provider,
|
||||
Organization organization,
|
||||
SutProvider<AssignSeatsToClientOrganizationCommand> sutProvider)
|
||||
{
|
||||
organization.Seats = 10;
|
||||
|
||||
organization.PlanType = PlanType.TeamsMonthly;
|
||||
|
||||
// Scale up 10 seats
|
||||
const int seats = 20;
|
||||
|
||||
var providerPlans = new List<ProviderPlan>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PlanType = PlanType.TeamsMonthly,
|
||||
ProviderId = provider.Id,
|
||||
PurchasedSeats = 0,
|
||||
// 100 minimum
|
||||
SeatMinimum = 100,
|
||||
AllocatedSeats = 50
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PlanType = PlanType.EnterpriseMonthly,
|
||||
ProviderId = provider.Id,
|
||||
PurchasedSeats = 0,
|
||||
SeatMinimum = 500,
|
||||
AllocatedSeats = 0
|
||||
}
|
||||
};
|
||||
|
||||
var providerPlan = providerPlans.First();
|
||||
|
||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
|
||||
|
||||
// 50 seats currently assigned with a seat minimum of 100
|
||||
sutProvider.GetDependency<IProviderBillingQueries>().GetAssignedSeatTotalForPlanOrThrow(provider.Id, providerPlan.PlanType).Returns(50);
|
||||
|
||||
await sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats);
|
||||
|
||||
// 50 assigned seats + 10 seat scale up = 60 seats, well below the 100 minimum
|
||||
await sutProvider.GetDependency<IPaymentService>().DidNotReceiveWithAnyArgs().AdjustSeats(
|
||||
Arg.Any<Provider>(),
|
||||
Arg.Any<Plan>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>());
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationRepository>().Received(1).ReplaceAsync(Arg.Is<Organization>(
|
||||
org => org.Id == organization.Id && org.Seats == seats));
|
||||
|
||||
await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
|
||||
pPlan => pPlan.AllocatedSeats == 60));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task AssignSeatsToClientOrganization_BelowToAbove_Succeeds(
|
||||
Provider provider,
|
||||
Organization organization,
|
||||
SutProvider<AssignSeatsToClientOrganizationCommand> sutProvider)
|
||||
{
|
||||
organization.Seats = 10;
|
||||
|
||||
organization.PlanType = PlanType.TeamsMonthly;
|
||||
|
||||
// Scale up 10 seats
|
||||
const int seats = 20;
|
||||
|
||||
var providerPlans = new List<ProviderPlan>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PlanType = PlanType.TeamsMonthly,
|
||||
ProviderId = provider.Id,
|
||||
PurchasedSeats = 0,
|
||||
// 100 minimum
|
||||
SeatMinimum = 100,
|
||||
AllocatedSeats = 95
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PlanType = PlanType.EnterpriseMonthly,
|
||||
ProviderId = provider.Id,
|
||||
PurchasedSeats = 0,
|
||||
SeatMinimum = 500,
|
||||
AllocatedSeats = 0
|
||||
}
|
||||
};
|
||||
|
||||
var providerPlan = providerPlans.First();
|
||||
|
||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
|
||||
|
||||
// 95 seats currently assigned with a seat minimum of 100
|
||||
sutProvider.GetDependency<IProviderBillingQueries>().GetAssignedSeatTotalForPlanOrThrow(provider.Id, providerPlan.PlanType).Returns(95);
|
||||
|
||||
await sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats);
|
||||
|
||||
// 95 current + 10 seat scale = 105 seats, 5 above the minimum
|
||||
await sutProvider.GetDependency<IPaymentService>().Received(1).AdjustSeats(
|
||||
provider,
|
||||
StaticStore.GetPlan(providerPlan.PlanType),
|
||||
providerPlan.SeatMinimum!.Value,
|
||||
105);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationRepository>().Received(1).ReplaceAsync(Arg.Is<Organization>(
|
||||
org => org.Id == organization.Id && org.Seats == seats));
|
||||
|
||||
// 105 total seats - 100 minimum = 5 purchased seats
|
||||
await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
|
||||
pPlan => pPlan.Id == providerPlan.Id && pPlan.PurchasedSeats == 5 && pPlan.AllocatedSeats == 105));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task AssignSeatsToClientOrganization_AboveToAbove_Succeeds(
|
||||
Provider provider,
|
||||
Organization organization,
|
||||
SutProvider<AssignSeatsToClientOrganizationCommand> sutProvider)
|
||||
{
|
||||
organization.Seats = 10;
|
||||
|
||||
organization.PlanType = PlanType.TeamsMonthly;
|
||||
|
||||
// Scale up 10 seats
|
||||
const int seats = 20;
|
||||
|
||||
var providerPlans = new List<ProviderPlan>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PlanType = PlanType.TeamsMonthly,
|
||||
ProviderId = provider.Id,
|
||||
// 10 additional purchased seats
|
||||
PurchasedSeats = 10,
|
||||
// 100 seat minimum
|
||||
SeatMinimum = 100,
|
||||
AllocatedSeats = 110
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PlanType = PlanType.EnterpriseMonthly,
|
||||
ProviderId = provider.Id,
|
||||
PurchasedSeats = 0,
|
||||
SeatMinimum = 500,
|
||||
AllocatedSeats = 0
|
||||
}
|
||||
};
|
||||
|
||||
var providerPlan = providerPlans.First();
|
||||
|
||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
|
||||
|
||||
// 110 seats currently assigned with a seat minimum of 100
|
||||
sutProvider.GetDependency<IProviderBillingQueries>().GetAssignedSeatTotalForPlanOrThrow(provider.Id, providerPlan.PlanType).Returns(110);
|
||||
|
||||
await sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats);
|
||||
|
||||
// 110 current + 10 seat scale up = 120 seats
|
||||
await sutProvider.GetDependency<IPaymentService>().Received(1).AdjustSeats(
|
||||
provider,
|
||||
StaticStore.GetPlan(providerPlan.PlanType),
|
||||
110,
|
||||
120);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationRepository>().Received(1).ReplaceAsync(Arg.Is<Organization>(
|
||||
org => org.Id == organization.Id && org.Seats == seats));
|
||||
|
||||
// 120 total seats - 100 seat minimum = 20 purchased seats
|
||||
await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
|
||||
pPlan => pPlan.Id == providerPlan.Id && pPlan.PurchasedSeats == 20 && pPlan.AllocatedSeats == 120));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task AssignSeatsToClientOrganization_AboveToBelow_Succeeds(
|
||||
Provider provider,
|
||||
Organization organization,
|
||||
SutProvider<AssignSeatsToClientOrganizationCommand> sutProvider)
|
||||
{
|
||||
organization.Seats = 50;
|
||||
|
||||
organization.PlanType = PlanType.TeamsMonthly;
|
||||
|
||||
// Scale down 30 seats
|
||||
const int seats = 20;
|
||||
|
||||
var providerPlans = new List<ProviderPlan>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PlanType = PlanType.TeamsMonthly,
|
||||
ProviderId = provider.Id,
|
||||
// 10 additional purchased seats
|
||||
PurchasedSeats = 10,
|
||||
// 100 seat minimum
|
||||
SeatMinimum = 100,
|
||||
AllocatedSeats = 110
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PlanType = PlanType.EnterpriseMonthly,
|
||||
ProviderId = provider.Id,
|
||||
PurchasedSeats = 0,
|
||||
SeatMinimum = 500,
|
||||
AllocatedSeats = 0
|
||||
}
|
||||
};
|
||||
|
||||
var providerPlan = providerPlans.First();
|
||||
|
||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
|
||||
|
||||
// 110 seats currently assigned with a seat minimum of 100
|
||||
sutProvider.GetDependency<IProviderBillingQueries>().GetAssignedSeatTotalForPlanOrThrow(provider.Id, providerPlan.PlanType).Returns(110);
|
||||
|
||||
await sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats);
|
||||
|
||||
// 110 seats - 30 scale down seats = 80 seats, below the 100 seat minimum.
|
||||
await sutProvider.GetDependency<IPaymentService>().Received(1).AdjustSeats(
|
||||
provider,
|
||||
StaticStore.GetPlan(providerPlan.PlanType),
|
||||
110,
|
||||
providerPlan.SeatMinimum!.Value);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationRepository>().Received(1).ReplaceAsync(Arg.Is<Organization>(
|
||||
org => org.Id == organization.Id && org.Seats == seats));
|
||||
|
||||
// Being below the seat minimum means no purchased seats.
|
||||
await sutProvider.GetDependency<IProviderPlanRepository>().Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
|
||||
pPlan => pPlan.Id == providerPlan.Id && pPlan.PurchasedSeats == 0 && pPlan.AllocatedSeats == 80));
|
||||
}
|
||||
}
|
@ -1,163 +0,0 @@
|
||||
using System.Linq.Expressions;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Commands.Implementations;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
|
||||
using static Bit.Core.Test.Billing.Utilities;
|
||||
|
||||
namespace Bit.Core.Test.Billing.Commands;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class CancelSubscriptionCommandTests
|
||||
{
|
||||
private const string _subscriptionId = "subscription_id";
|
||||
private const string _cancellingUserIdKey = "cancellingUserId";
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CancelSubscription_SubscriptionInactive_ThrowsGatewayException(
|
||||
SutProvider<CancelSubscriptionCommand> sutProvider)
|
||||
{
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Status = "canceled"
|
||||
};
|
||||
|
||||
await ThrowsContactSupportAsync(() =>
|
||||
sutProvider.Sut.CancelSubscription(subscription, new OffboardingSurveyResponse(), false));
|
||||
|
||||
await DidNotUpdateSubscription(sutProvider);
|
||||
|
||||
await DidNotCancelSubscription(sutProvider);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CancelSubscription_CancelImmediately_BelongsToOrganization_UpdatesSubscription_CancelSubscriptionImmediately(
|
||||
SutProvider<CancelSubscriptionCommand> sutProvider)
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = _subscriptionId,
|
||||
Status = "active",
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "organizationId", "organization_id" }
|
||||
}
|
||||
};
|
||||
|
||||
var offboardingSurveyResponse = new OffboardingSurveyResponse
|
||||
{
|
||||
UserId = userId,
|
||||
Reason = "missing_features",
|
||||
Feedback = "Lorem ipsum"
|
||||
};
|
||||
|
||||
await sutProvider.Sut.CancelSubscription(subscription, offboardingSurveyResponse, true);
|
||||
|
||||
await UpdatedSubscriptionWith(sutProvider, options => options.Metadata[_cancellingUserIdKey] == userId.ToString());
|
||||
|
||||
await CancelledSubscriptionWith(sutProvider, options =>
|
||||
options.CancellationDetails.Comment == offboardingSurveyResponse.Feedback &&
|
||||
options.CancellationDetails.Feedback == offboardingSurveyResponse.Reason);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CancelSubscription_CancelImmediately_BelongsToUser_CancelSubscriptionImmediately(
|
||||
SutProvider<CancelSubscriptionCommand> sutProvider)
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = _subscriptionId,
|
||||
Status = "active",
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "userId", "user_id" }
|
||||
}
|
||||
};
|
||||
|
||||
var offboardingSurveyResponse = new OffboardingSurveyResponse
|
||||
{
|
||||
UserId = userId,
|
||||
Reason = "missing_features",
|
||||
Feedback = "Lorem ipsum"
|
||||
};
|
||||
|
||||
await sutProvider.Sut.CancelSubscription(subscription, offboardingSurveyResponse, true);
|
||||
|
||||
await DidNotUpdateSubscription(sutProvider);
|
||||
|
||||
await CancelledSubscriptionWith(sutProvider, options =>
|
||||
options.CancellationDetails.Comment == offboardingSurveyResponse.Feedback &&
|
||||
options.CancellationDetails.Feedback == offboardingSurveyResponse.Reason);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CancelSubscription_DoNotCancelImmediately_UpdateSubscriptionToCancelAtEndOfPeriod(
|
||||
Organization organization,
|
||||
SutProvider<CancelSubscriptionCommand> sutProvider)
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
|
||||
organization.ExpirationDate = DateTime.UtcNow.AddDays(5);
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = _subscriptionId,
|
||||
Status = "active"
|
||||
};
|
||||
|
||||
var offboardingSurveyResponse = new OffboardingSurveyResponse
|
||||
{
|
||||
UserId = userId,
|
||||
Reason = "missing_features",
|
||||
Feedback = "Lorem ipsum"
|
||||
};
|
||||
|
||||
await sutProvider.Sut.CancelSubscription(subscription, offboardingSurveyResponse, false);
|
||||
|
||||
await UpdatedSubscriptionWith(sutProvider, options =>
|
||||
options.CancelAtPeriodEnd == true &&
|
||||
options.CancellationDetails.Comment == offboardingSurveyResponse.Feedback &&
|
||||
options.CancellationDetails.Feedback == offboardingSurveyResponse.Reason &&
|
||||
options.Metadata[_cancellingUserIdKey] == userId.ToString());
|
||||
|
||||
await DidNotCancelSubscription(sutProvider);
|
||||
}
|
||||
|
||||
private static Task<Subscription> DidNotCancelSubscription(SutProvider<CancelSubscriptionCommand> sutProvider)
|
||||
=> sutProvider
|
||||
.GetDependency<IStripeAdapter>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.SubscriptionCancelAsync(Arg.Any<string>(), Arg.Any<SubscriptionCancelOptions>());
|
||||
|
||||
private static Task<Subscription> DidNotUpdateSubscription(SutProvider<CancelSubscriptionCommand> sutProvider)
|
||||
=> sutProvider
|
||||
.GetDependency<IStripeAdapter>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.SubscriptionUpdateAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
|
||||
|
||||
private static Task<Subscription> CancelledSubscriptionWith(
|
||||
SutProvider<CancelSubscriptionCommand> sutProvider,
|
||||
Expression<Predicate<SubscriptionCancelOptions>> predicate)
|
||||
=> sutProvider
|
||||
.GetDependency<IStripeAdapter>()
|
||||
.Received(1)
|
||||
.SubscriptionCancelAsync(_subscriptionId, Arg.Is(predicate));
|
||||
|
||||
private static Task<Subscription> UpdatedSubscriptionWith(
|
||||
SutProvider<CancelSubscriptionCommand> sutProvider,
|
||||
Expression<Predicate<SubscriptionUpdateOptions>> predicate)
|
||||
=> sutProvider
|
||||
.GetDependency<IStripeAdapter>()
|
||||
.Received(1)
|
||||
.SubscriptionUpdateAsync(_subscriptionId, Arg.Is(predicate));
|
||||
}
|
@ -1,129 +0,0 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.Billing.Commands.Implementations;
|
||||
using Bit.Core.Billing.Queries;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
using GlobalSettings = Bit.Core.Settings.GlobalSettings;
|
||||
|
||||
namespace Bit.Core.Test.Billing.Commands;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class CreateCustomerCommandTests
|
||||
{
|
||||
private const string _customerId = "customer_id";
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateCustomer_ForClientOrg_ProviderNull_ThrowsArgumentNullException(
|
||||
Organization organization,
|
||||
SutProvider<CreateCustomerCommand> sutProvider) =>
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => sutProvider.Sut.CreateCustomer(null, organization));
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateCustomer_ForClientOrg_OrganizationNull_ThrowsArgumentNullException(
|
||||
Provider provider,
|
||||
SutProvider<CreateCustomerCommand> sutProvider) =>
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => sutProvider.Sut.CreateCustomer(provider, null));
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateCustomer_ForClientOrg_HasGatewayCustomerId_NoOp(
|
||||
Provider provider,
|
||||
Organization organization,
|
||||
SutProvider<CreateCustomerCommand> sutProvider)
|
||||
{
|
||||
organization.GatewayCustomerId = _customerId;
|
||||
|
||||
await sutProvider.Sut.CreateCustomer(provider, organization);
|
||||
|
||||
await sutProvider.GetDependency<ISubscriberQueries>().DidNotReceiveWithAnyArgs()
|
||||
.GetCustomerOrThrow(Arg.Any<ISubscriber>(), Arg.Any<CustomerGetOptions>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateCustomer_ForClientOrg_Succeeds(
|
||||
Provider provider,
|
||||
Organization organization,
|
||||
SutProvider<CreateCustomerCommand> sutProvider)
|
||||
{
|
||||
organization.GatewayCustomerId = null;
|
||||
organization.Name = "Name";
|
||||
organization.BusinessName = "BusinessName";
|
||||
|
||||
var providerCustomer = new Customer
|
||||
{
|
||||
Address = new Address
|
||||
{
|
||||
Country = "USA",
|
||||
PostalCode = "12345",
|
||||
Line1 = "123 Main St.",
|
||||
Line2 = "Unit 4",
|
||||
City = "Fake Town",
|
||||
State = "Fake State"
|
||||
},
|
||||
TaxIds = new StripeList<TaxId>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new TaxId { Type = "TYPE", Value = "VALUE" }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<ISubscriberQueries>().GetCustomerOrThrow(provider, Arg.Is<CustomerGetOptions>(
|
||||
options => options.Expand.FirstOrDefault() == "tax_ids"))
|
||||
.Returns(providerCustomer);
|
||||
|
||||
sutProvider.GetDependency<IGlobalSettings>().BaseServiceUri
|
||||
.Returns(new GlobalSettings.BaseServiceUriSettings(new GlobalSettings()) { CloudRegion = "US" });
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>().CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(
|
||||
options =>
|
||||
options.Address.Country == providerCustomer.Address.Country &&
|
||||
options.Address.PostalCode == providerCustomer.Address.PostalCode &&
|
||||
options.Address.Line1 == providerCustomer.Address.Line1 &&
|
||||
options.Address.Line2 == providerCustomer.Address.Line2 &&
|
||||
options.Address.City == providerCustomer.Address.City &&
|
||||
options.Address.State == providerCustomer.Address.State &&
|
||||
options.Name == organization.DisplayName() &&
|
||||
options.Description == $"{provider.Name} Client Organization" &&
|
||||
options.Email == provider.BillingEmail &&
|
||||
options.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Organization" &&
|
||||
options.InvoiceSettings.CustomFields.FirstOrDefault().Value == "Name" &&
|
||||
options.Metadata["region"] == "US" &&
|
||||
options.TaxIdData.FirstOrDefault().Type == providerCustomer.TaxIds.FirstOrDefault().Type &&
|
||||
options.TaxIdData.FirstOrDefault().Value == providerCustomer.TaxIds.FirstOrDefault().Value))
|
||||
.Returns(new Customer
|
||||
{
|
||||
Id = "customer_id"
|
||||
});
|
||||
|
||||
await sutProvider.Sut.CreateCustomer(provider, organization);
|
||||
|
||||
await sutProvider.GetDependency<IStripeAdapter>().Received(1).CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(
|
||||
options =>
|
||||
options.Address.Country == providerCustomer.Address.Country &&
|
||||
options.Address.PostalCode == providerCustomer.Address.PostalCode &&
|
||||
options.Address.Line1 == providerCustomer.Address.Line1 &&
|
||||
options.Address.Line2 == providerCustomer.Address.Line2 &&
|
||||
options.Address.City == providerCustomer.Address.City &&
|
||||
options.Address.State == providerCustomer.Address.State &&
|
||||
options.Name == organization.DisplayName() &&
|
||||
options.Description == $"{provider.Name} Client Organization" &&
|
||||
options.Email == provider.BillingEmail &&
|
||||
options.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Organization" &&
|
||||
options.InvoiceSettings.CustomFields.FirstOrDefault().Value == "Name" &&
|
||||
options.Metadata["region"] == "US" &&
|
||||
options.TaxIdData.FirstOrDefault().Type == providerCustomer.TaxIds.FirstOrDefault().Type &&
|
||||
options.TaxIdData.FirstOrDefault().Value == providerCustomer.TaxIds.FirstOrDefault().Value));
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationRepository>().Received(1).ReplaceAsync(Arg.Is<Organization>(
|
||||
org => org.GatewayCustomerId == "customer_id"));
|
||||
}
|
||||
}
|
@ -1,358 +0,0 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Commands.Implementations;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ReturnsExtensions;
|
||||
using Xunit;
|
||||
using static Bit.Core.Test.Billing.Utilities;
|
||||
using BT = Braintree;
|
||||
using S = Stripe;
|
||||
|
||||
namespace Bit.Core.Test.Billing.Commands;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class RemovePaymentMethodCommandTests
|
||||
{
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemovePaymentMethod_NullOrganization_ArgumentNullException(
|
||||
SutProvider<RemovePaymentMethodCommand> sutProvider) =>
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => sutProvider.Sut.RemovePaymentMethod(null));
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemovePaymentMethod_NonStripeGateway_ContactSupport(
|
||||
Organization organization,
|
||||
SutProvider<RemovePaymentMethodCommand> sutProvider)
|
||||
{
|
||||
organization.Gateway = GatewayType.BitPay;
|
||||
|
||||
await ThrowsContactSupportAsync(() => sutProvider.Sut.RemovePaymentMethod(organization));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemovePaymentMethod_NoGatewayCustomerId_ContactSupport(
|
||||
Organization organization,
|
||||
SutProvider<RemovePaymentMethodCommand> sutProvider)
|
||||
{
|
||||
organization.Gateway = GatewayType.Stripe;
|
||||
organization.GatewayCustomerId = null;
|
||||
|
||||
await ThrowsContactSupportAsync(() => sutProvider.Sut.RemovePaymentMethod(organization));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemovePaymentMethod_NoStripeCustomer_ContactSupport(
|
||||
Organization organization,
|
||||
SutProvider<RemovePaymentMethodCommand> sutProvider)
|
||||
{
|
||||
organization.Gateway = GatewayType.Stripe;
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.CustomerGetAsync(organization.GatewayCustomerId, Arg.Any<S.CustomerGetOptions>())
|
||||
.ReturnsNull();
|
||||
|
||||
await ThrowsContactSupportAsync(() => sutProvider.Sut.RemovePaymentMethod(organization));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemovePaymentMethod_Braintree_NoCustomer_ContactSupport(
|
||||
Organization organization,
|
||||
SutProvider<RemovePaymentMethodCommand> sutProvider)
|
||||
{
|
||||
organization.Gateway = GatewayType.Stripe;
|
||||
|
||||
const string braintreeCustomerId = "1";
|
||||
|
||||
var stripeCustomer = new S.Customer
|
||||
{
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "btCustomerId", braintreeCustomerId }
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.CustomerGetAsync(organization.GatewayCustomerId, Arg.Any<S.CustomerGetOptions>())
|
||||
.Returns(stripeCustomer);
|
||||
|
||||
var (braintreeGateway, customerGateway, paymentMethodGateway) = Setup(sutProvider.GetDependency<BT.IBraintreeGateway>());
|
||||
|
||||
customerGateway.FindAsync(braintreeCustomerId).ReturnsNull();
|
||||
|
||||
braintreeGateway.Customer.Returns(customerGateway);
|
||||
|
||||
await ThrowsContactSupportAsync(() => sutProvider.Sut.RemovePaymentMethod(organization));
|
||||
|
||||
await customerGateway.Received(1).FindAsync(braintreeCustomerId);
|
||||
|
||||
await customerGateway.DidNotReceiveWithAnyArgs()
|
||||
.UpdateAsync(Arg.Any<string>(), Arg.Any<BT.CustomerRequest>());
|
||||
|
||||
await paymentMethodGateway.DidNotReceiveWithAnyArgs().DeleteAsync(Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemovePaymentMethod_Braintree_NoPaymentMethod_NoOp(
|
||||
Organization organization,
|
||||
SutProvider<RemovePaymentMethodCommand> sutProvider)
|
||||
{
|
||||
organization.Gateway = GatewayType.Stripe;
|
||||
|
||||
const string braintreeCustomerId = "1";
|
||||
|
||||
var stripeCustomer = new S.Customer
|
||||
{
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "btCustomerId", braintreeCustomerId }
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.CustomerGetAsync(organization.GatewayCustomerId, Arg.Any<S.CustomerGetOptions>())
|
||||
.Returns(stripeCustomer);
|
||||
|
||||
var (_, customerGateway, paymentMethodGateway) = Setup(sutProvider.GetDependency<BT.IBraintreeGateway>());
|
||||
|
||||
var braintreeCustomer = Substitute.For<BT.Customer>();
|
||||
|
||||
braintreeCustomer.PaymentMethods.Returns(Array.Empty<BT.PaymentMethod>());
|
||||
|
||||
customerGateway.FindAsync(braintreeCustomerId).Returns(braintreeCustomer);
|
||||
|
||||
await sutProvider.Sut.RemovePaymentMethod(organization);
|
||||
|
||||
await customerGateway.Received(1).FindAsync(braintreeCustomerId);
|
||||
|
||||
await customerGateway.DidNotReceiveWithAnyArgs().UpdateAsync(Arg.Any<string>(), Arg.Any<BT.CustomerRequest>());
|
||||
|
||||
await paymentMethodGateway.DidNotReceiveWithAnyArgs().DeleteAsync(Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemovePaymentMethod_Braintree_CustomerUpdateFails_ContactSupport(
|
||||
Organization organization,
|
||||
SutProvider<RemovePaymentMethodCommand> sutProvider)
|
||||
{
|
||||
organization.Gateway = GatewayType.Stripe;
|
||||
|
||||
const string braintreeCustomerId = "1";
|
||||
const string braintreePaymentMethodToken = "TOKEN";
|
||||
|
||||
var stripeCustomer = new S.Customer
|
||||
{
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "btCustomerId", braintreeCustomerId }
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.CustomerGetAsync(organization.GatewayCustomerId, Arg.Any<S.CustomerGetOptions>())
|
||||
.Returns(stripeCustomer);
|
||||
|
||||
var (_, customerGateway, paymentMethodGateway) = Setup(sutProvider.GetDependency<BT.IBraintreeGateway>());
|
||||
|
||||
var braintreeCustomer = Substitute.For<BT.Customer>();
|
||||
|
||||
var paymentMethod = Substitute.For<BT.PaymentMethod>();
|
||||
paymentMethod.Token.Returns(braintreePaymentMethodToken);
|
||||
paymentMethod.IsDefault.Returns(true);
|
||||
|
||||
braintreeCustomer.PaymentMethods.Returns(new[]
|
||||
{
|
||||
paymentMethod
|
||||
});
|
||||
|
||||
customerGateway.FindAsync(braintreeCustomerId).Returns(braintreeCustomer);
|
||||
|
||||
var updateBraintreeCustomerResult = Substitute.For<BT.Result<BT.Customer>>();
|
||||
updateBraintreeCustomerResult.IsSuccess().Returns(false);
|
||||
|
||||
customerGateway.UpdateAsync(
|
||||
braintreeCustomerId,
|
||||
Arg.Is<BT.CustomerRequest>(request => request.DefaultPaymentMethodToken == null))
|
||||
.Returns(updateBraintreeCustomerResult);
|
||||
|
||||
await ThrowsContactSupportAsync(() => sutProvider.Sut.RemovePaymentMethod(organization));
|
||||
|
||||
await customerGateway.Received(1).FindAsync(braintreeCustomerId);
|
||||
|
||||
await customerGateway.Received(1).UpdateAsync(braintreeCustomerId, Arg.Is<BT.CustomerRequest>(request =>
|
||||
request.DefaultPaymentMethodToken == null));
|
||||
|
||||
await paymentMethodGateway.DidNotReceiveWithAnyArgs().DeleteAsync(paymentMethod.Token);
|
||||
|
||||
await customerGateway.DidNotReceive().UpdateAsync(braintreeCustomerId, Arg.Is<BT.CustomerRequest>(request =>
|
||||
request.DefaultPaymentMethodToken == paymentMethod.Token));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemovePaymentMethod_Braintree_PaymentMethodDeleteFails_RollBack_ContactSupport(
|
||||
Organization organization,
|
||||
SutProvider<RemovePaymentMethodCommand> sutProvider)
|
||||
{
|
||||
organization.Gateway = GatewayType.Stripe;
|
||||
|
||||
const string braintreeCustomerId = "1";
|
||||
const string braintreePaymentMethodToken = "TOKEN";
|
||||
|
||||
var stripeCustomer = new S.Customer
|
||||
{
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "btCustomerId", braintreeCustomerId }
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.CustomerGetAsync(organization.GatewayCustomerId, Arg.Any<S.CustomerGetOptions>())
|
||||
.Returns(stripeCustomer);
|
||||
|
||||
var (_, customerGateway, paymentMethodGateway) = Setup(sutProvider.GetDependency<BT.IBraintreeGateway>());
|
||||
|
||||
var braintreeCustomer = Substitute.For<BT.Customer>();
|
||||
|
||||
var paymentMethod = Substitute.For<BT.PaymentMethod>();
|
||||
paymentMethod.Token.Returns(braintreePaymentMethodToken);
|
||||
paymentMethod.IsDefault.Returns(true);
|
||||
|
||||
braintreeCustomer.PaymentMethods.Returns(new[]
|
||||
{
|
||||
paymentMethod
|
||||
});
|
||||
|
||||
customerGateway.FindAsync(braintreeCustomerId).Returns(braintreeCustomer);
|
||||
|
||||
var updateBraintreeCustomerResult = Substitute.For<BT.Result<BT.Customer>>();
|
||||
updateBraintreeCustomerResult.IsSuccess().Returns(true);
|
||||
|
||||
customerGateway.UpdateAsync(braintreeCustomerId, Arg.Any<BT.CustomerRequest>())
|
||||
.Returns(updateBraintreeCustomerResult);
|
||||
|
||||
var deleteBraintreePaymentMethodResult = Substitute.For<BT.Result<BT.PaymentMethod>>();
|
||||
deleteBraintreePaymentMethodResult.IsSuccess().Returns(false);
|
||||
|
||||
paymentMethodGateway.DeleteAsync(paymentMethod.Token).Returns(deleteBraintreePaymentMethodResult);
|
||||
|
||||
await ThrowsContactSupportAsync(() => sutProvider.Sut.RemovePaymentMethod(organization));
|
||||
|
||||
await customerGateway.Received(1).FindAsync(braintreeCustomerId);
|
||||
|
||||
await customerGateway.Received(1).UpdateAsync(braintreeCustomerId, Arg.Is<BT.CustomerRequest>(request =>
|
||||
request.DefaultPaymentMethodToken == null));
|
||||
|
||||
await paymentMethodGateway.Received(1).DeleteAsync(paymentMethod.Token);
|
||||
|
||||
await customerGateway.Received(1).UpdateAsync(braintreeCustomerId, Arg.Is<BT.CustomerRequest>(request =>
|
||||
request.DefaultPaymentMethodToken == paymentMethod.Token));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemovePaymentMethod_Stripe_Legacy_RemovesSources(
|
||||
Organization organization,
|
||||
SutProvider<RemovePaymentMethodCommand> sutProvider)
|
||||
{
|
||||
organization.Gateway = GatewayType.Stripe;
|
||||
|
||||
const string bankAccountId = "bank_account_id";
|
||||
const string cardId = "card_id";
|
||||
|
||||
var sources = new List<S.IPaymentSource>
|
||||
{
|
||||
new S.BankAccount { Id = bankAccountId }, new S.Card { Id = cardId }
|
||||
};
|
||||
|
||||
var stripeCustomer = new S.Customer { Sources = new S.StripeList<S.IPaymentSource> { Data = sources } };
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
stripeAdapter
|
||||
.CustomerGetAsync(organization.GatewayCustomerId, Arg.Any<S.CustomerGetOptions>())
|
||||
.Returns(stripeCustomer);
|
||||
|
||||
stripeAdapter
|
||||
.PaymentMethodListAutoPagingAsync(Arg.Any<S.PaymentMethodListOptions>())
|
||||
.Returns(GetPaymentMethodsAsync(new List<S.PaymentMethod>()));
|
||||
|
||||
await sutProvider.Sut.RemovePaymentMethod(organization);
|
||||
|
||||
await stripeAdapter.Received(1).BankAccountDeleteAsync(stripeCustomer.Id, bankAccountId);
|
||||
|
||||
await stripeAdapter.Received(1).CardDeleteAsync(stripeCustomer.Id, cardId);
|
||||
|
||||
await stripeAdapter.DidNotReceiveWithAnyArgs()
|
||||
.PaymentMethodDetachAsync(Arg.Any<string>(), Arg.Any<S.PaymentMethodDetachOptions>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemovePaymentMethod_Stripe_DetachesPaymentMethods(
|
||||
Organization organization,
|
||||
SutProvider<RemovePaymentMethodCommand> sutProvider)
|
||||
{
|
||||
organization.Gateway = GatewayType.Stripe;
|
||||
const string bankAccountId = "bank_account_id";
|
||||
const string cardId = "card_id";
|
||||
|
||||
var sources = new List<S.IPaymentSource>();
|
||||
|
||||
var stripeCustomer = new S.Customer { Sources = new S.StripeList<S.IPaymentSource> { Data = sources } };
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
stripeAdapter
|
||||
.CustomerGetAsync(organization.GatewayCustomerId, Arg.Any<S.CustomerGetOptions>())
|
||||
.Returns(stripeCustomer);
|
||||
|
||||
stripeAdapter
|
||||
.PaymentMethodListAutoPagingAsync(Arg.Any<S.PaymentMethodListOptions>())
|
||||
.Returns(GetPaymentMethodsAsync(new List<S.PaymentMethod>
|
||||
{
|
||||
new ()
|
||||
{
|
||||
Id = bankAccountId
|
||||
},
|
||||
new ()
|
||||
{
|
||||
Id = cardId
|
||||
}
|
||||
}));
|
||||
|
||||
await sutProvider.Sut.RemovePaymentMethod(organization);
|
||||
|
||||
await stripeAdapter.DidNotReceiveWithAnyArgs().BankAccountDeleteAsync(Arg.Any<string>(), Arg.Any<string>());
|
||||
|
||||
await stripeAdapter.DidNotReceiveWithAnyArgs().CardDeleteAsync(Arg.Any<string>(), Arg.Any<string>());
|
||||
|
||||
await stripeAdapter.Received(1)
|
||||
.PaymentMethodDetachAsync(bankAccountId, Arg.Any<S.PaymentMethodDetachOptions>());
|
||||
|
||||
await stripeAdapter.Received(1)
|
||||
.PaymentMethodDetachAsync(cardId, Arg.Any<S.PaymentMethodDetachOptions>());
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<S.PaymentMethod> GetPaymentMethodsAsync(
|
||||
IEnumerable<S.PaymentMethod> paymentMethods)
|
||||
{
|
||||
foreach (var paymentMethod in paymentMethods)
|
||||
{
|
||||
yield return paymentMethod;
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static (BT.IBraintreeGateway, BT.ICustomerGateway, BT.IPaymentMethodGateway) Setup(
|
||||
BT.IBraintreeGateway braintreeGateway)
|
||||
{
|
||||
var customerGateway = Substitute.For<BT.ICustomerGateway>();
|
||||
var paymentMethodGateway = Substitute.For<BT.IPaymentMethodGateway>();
|
||||
|
||||
braintreeGateway.Customer.Returns(customerGateway);
|
||||
braintreeGateway.PaymentMethod.Returns(paymentMethodGateway);
|
||||
|
||||
return (braintreeGateway, customerGateway, paymentMethodGateway);
|
||||
}
|
||||
}
|
@ -1,420 +0,0 @@
|
||||
using System.Net;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Commands.Implementations;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Entities;
|
||||
using Bit.Core.Billing.Repositories;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
|
||||
using static Bit.Core.Test.Billing.Utilities;
|
||||
|
||||
namespace Bit.Core.Test.Billing.Commands;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class StartSubscriptionCommandTests
|
||||
{
|
||||
private const string _customerId = "customer_id";
|
||||
private const string _subscriptionId = "subscription_id";
|
||||
|
||||
// These tests are only trying to assert on the thrown exceptions and thus use the least amount of data setup possible.
|
||||
#region Error Cases
|
||||
[Theory, BitAutoData]
|
||||
public async Task StartSubscription_NullProvider_ThrowsArgumentNullException(
|
||||
SutProvider<StartSubscriptionCommand> sutProvider,
|
||||
TaxInfo taxInfo) =>
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => sutProvider.Sut.StartSubscription(null, taxInfo));
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task StartSubscription_NullTaxInfo_ThrowsArgumentNullException(
|
||||
SutProvider<StartSubscriptionCommand> sutProvider,
|
||||
Provider provider) =>
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => sutProvider.Sut.StartSubscription(provider, null));
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task StartSubscription_AlreadyHasGatewaySubscriptionId_ThrowsBillingException(
|
||||
SutProvider<StartSubscriptionCommand> sutProvider,
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
{
|
||||
provider.GatewayCustomerId = _customerId;
|
||||
|
||||
provider.GatewaySubscriptionId = _subscriptionId;
|
||||
|
||||
await ThrowsContactSupportAsync(() => sutProvider.Sut.StartSubscription(provider, taxInfo));
|
||||
|
||||
await DidNotRetrieveCustomerAsync(sutProvider);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task StartSubscription_MissingCountry_ThrowsBillingException(
|
||||
SutProvider<StartSubscriptionCommand> sutProvider,
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
{
|
||||
provider.GatewayCustomerId = _customerId;
|
||||
|
||||
provider.GatewaySubscriptionId = null;
|
||||
|
||||
taxInfo.BillingAddressCountry = null;
|
||||
|
||||
await ThrowsContactSupportAsync(() => sutProvider.Sut.StartSubscription(provider, taxInfo));
|
||||
|
||||
await DidNotRetrieveCustomerAsync(sutProvider);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task StartSubscription_MissingPostalCode_ThrowsBillingException(
|
||||
SutProvider<StartSubscriptionCommand> sutProvider,
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
{
|
||||
provider.GatewayCustomerId = _customerId;
|
||||
|
||||
provider.GatewaySubscriptionId = null;
|
||||
|
||||
taxInfo.BillingAddressPostalCode = null;
|
||||
|
||||
await ThrowsContactSupportAsync(() => sutProvider.Sut.StartSubscription(provider, taxInfo));
|
||||
|
||||
await DidNotRetrieveCustomerAsync(sutProvider);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task StartSubscription_MissingStripeCustomer_ThrowsBillingException(
|
||||
SutProvider<StartSubscriptionCommand> sutProvider,
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
{
|
||||
provider.GatewayCustomerId = _customerId;
|
||||
|
||||
provider.GatewaySubscriptionId = null;
|
||||
|
||||
SetCustomerRetrieval(sutProvider, null);
|
||||
|
||||
await ThrowsContactSupportAsync(() => sutProvider.Sut.StartSubscription(provider, taxInfo));
|
||||
|
||||
await DidNotRetrieveProviderPlansAsync(sutProvider);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task StartSubscription_NoProviderPlans_ThrowsBillingException(
|
||||
SutProvider<StartSubscriptionCommand> sutProvider,
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
{
|
||||
provider.GatewayCustomerId = _customerId;
|
||||
|
||||
provider.GatewaySubscriptionId = null;
|
||||
|
||||
SetCustomerRetrieval(sutProvider, new Customer
|
||||
{
|
||||
Id = _customerId,
|
||||
Tax = new CustomerTax
|
||||
{
|
||||
AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported
|
||||
}
|
||||
});
|
||||
|
||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
|
||||
.Returns(new List<ProviderPlan>());
|
||||
|
||||
await ThrowsContactSupportAsync(() => sutProvider.Sut.StartSubscription(provider, taxInfo));
|
||||
|
||||
await DidNotCreateSubscriptionAsync(sutProvider);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task StartSubscription_NoProviderTeamsPlan_ThrowsBillingException(
|
||||
SutProvider<StartSubscriptionCommand> sutProvider,
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
{
|
||||
provider.GatewayCustomerId = _customerId;
|
||||
|
||||
provider.GatewaySubscriptionId = null;
|
||||
|
||||
SetCustomerRetrieval(sutProvider, new Customer
|
||||
{
|
||||
Id = _customerId,
|
||||
Tax = new CustomerTax
|
||||
{
|
||||
AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported
|
||||
}
|
||||
});
|
||||
|
||||
var providerPlans = new List<ProviderPlan>
|
||||
{
|
||||
new ()
|
||||
{
|
||||
PlanType = PlanType.EnterpriseMonthly
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
|
||||
.Returns(providerPlans);
|
||||
|
||||
await ThrowsContactSupportAsync(() => sutProvider.Sut.StartSubscription(provider, taxInfo));
|
||||
|
||||
await DidNotCreateSubscriptionAsync(sutProvider);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task StartSubscription_NoProviderEnterprisePlan_ThrowsBillingException(
|
||||
SutProvider<StartSubscriptionCommand> sutProvider,
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
{
|
||||
provider.GatewayCustomerId = _customerId;
|
||||
|
||||
provider.GatewaySubscriptionId = null;
|
||||
|
||||
SetCustomerRetrieval(sutProvider, new Customer
|
||||
{
|
||||
Id = _customerId,
|
||||
Tax = new CustomerTax
|
||||
{
|
||||
AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported
|
||||
}
|
||||
});
|
||||
|
||||
var providerPlans = new List<ProviderPlan>
|
||||
{
|
||||
new ()
|
||||
{
|
||||
PlanType = PlanType.TeamsMonthly
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
|
||||
.Returns(providerPlans);
|
||||
|
||||
await ThrowsContactSupportAsync(() => sutProvider.Sut.StartSubscription(provider, taxInfo));
|
||||
|
||||
await DidNotCreateSubscriptionAsync(sutProvider);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task StartSubscription_SubscriptionIncomplete_ThrowsBillingException(
|
||||
SutProvider<StartSubscriptionCommand> sutProvider,
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
{
|
||||
provider.GatewayCustomerId = _customerId;
|
||||
|
||||
provider.GatewaySubscriptionId = null;
|
||||
|
||||
SetCustomerRetrieval(sutProvider, new Customer
|
||||
{
|
||||
Id = _customerId,
|
||||
Tax = new CustomerTax
|
||||
{
|
||||
AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported
|
||||
}
|
||||
});
|
||||
|
||||
var providerPlans = new List<ProviderPlan>
|
||||
{
|
||||
new ()
|
||||
{
|
||||
PlanType = PlanType.TeamsMonthly,
|
||||
SeatMinimum = 100
|
||||
},
|
||||
new ()
|
||||
{
|
||||
PlanType = PlanType.EnterpriseMonthly,
|
||||
SeatMinimum = 100
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
|
||||
.Returns(providerPlans);
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(new Subscription
|
||||
{
|
||||
Id = _subscriptionId,
|
||||
Status = StripeConstants.SubscriptionStatus.Incomplete
|
||||
});
|
||||
|
||||
await ThrowsContactSupportAsync(() => sutProvider.Sut.StartSubscription(provider, taxInfo));
|
||||
|
||||
await sutProvider.GetDependency<IProviderRepository>().Received(1).ReplaceAsync(provider);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Success Cases
|
||||
[Theory, BitAutoData]
|
||||
public async Task StartSubscription_ExistingCustomer_Succeeds(
|
||||
SutProvider<StartSubscriptionCommand> sutProvider,
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
{
|
||||
provider.GatewayCustomerId = _customerId;
|
||||
|
||||
provider.GatewaySubscriptionId = null;
|
||||
|
||||
SetCustomerRetrieval(sutProvider, new Customer
|
||||
{
|
||||
Id = _customerId,
|
||||
Tax = new CustomerTax
|
||||
{
|
||||
AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported
|
||||
}
|
||||
});
|
||||
|
||||
var providerPlans = new List<ProviderPlan>
|
||||
{
|
||||
new ()
|
||||
{
|
||||
PlanType = PlanType.TeamsMonthly,
|
||||
SeatMinimum = 100
|
||||
},
|
||||
new ()
|
||||
{
|
||||
PlanType = PlanType.EnterpriseMonthly,
|
||||
SeatMinimum = 100
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
|
||||
.Returns(providerPlans);
|
||||
|
||||
var teamsPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
||||
var enterprisePlan = StaticStore.GetPlan(PlanType.EnterpriseMonthly);
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
|
||||
sub =>
|
||||
sub.AutomaticTax.Enabled == true &&
|
||||
sub.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice &&
|
||||
sub.Customer == _customerId &&
|
||||
sub.DaysUntilDue == 30 &&
|
||||
sub.Items.Count == 2 &&
|
||||
sub.Items.ElementAt(0).Price == teamsPlan.PasswordManager.StripeSeatPlanId &&
|
||||
sub.Items.ElementAt(0).Quantity == 100 &&
|
||||
sub.Items.ElementAt(1).Price == enterprisePlan.PasswordManager.StripeSeatPlanId &&
|
||||
sub.Items.ElementAt(1).Quantity == 100 &&
|
||||
sub.Metadata["providerId"] == provider.Id.ToString() &&
|
||||
sub.OffSession == true &&
|
||||
sub.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations)).Returns(new Subscription
|
||||
{
|
||||
Id = _subscriptionId,
|
||||
Status = StripeConstants.SubscriptionStatus.Active
|
||||
});
|
||||
|
||||
await sutProvider.Sut.StartSubscription(provider, taxInfo);
|
||||
|
||||
await sutProvider.GetDependency<IProviderRepository>().Received(1).ReplaceAsync(provider);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task StartSubscription_NewCustomer_Succeeds(
|
||||
SutProvider<StartSubscriptionCommand> sutProvider,
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
{
|
||||
provider.GatewayCustomerId = null;
|
||||
|
||||
provider.GatewaySubscriptionId = null;
|
||||
|
||||
provider.Name = "MSP";
|
||||
|
||||
taxInfo.BillingAddressCountry = "AD";
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>().CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
|
||||
o.Address.Country == taxInfo.BillingAddressCountry &&
|
||||
o.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
|
||||
o.Address.Line1 == taxInfo.BillingAddressLine1 &&
|
||||
o.Address.Line2 == taxInfo.BillingAddressLine2 &&
|
||||
o.Address.City == taxInfo.BillingAddressCity &&
|
||||
o.Address.State == taxInfo.BillingAddressState &&
|
||||
o.Coupon == "msp-discount-35" &&
|
||||
o.Description == WebUtility.HtmlDecode(provider.BusinessName) &&
|
||||
o.Email == provider.BillingEmail &&
|
||||
o.Expand.FirstOrDefault() == "tax" &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
|
||||
o.Metadata["region"] == "" &&
|
||||
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
|
||||
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber))
|
||||
.Returns(new Customer
|
||||
{
|
||||
Id = _customerId,
|
||||
Tax = new CustomerTax
|
||||
{
|
||||
AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported
|
||||
}
|
||||
});
|
||||
|
||||
var providerPlans = new List<ProviderPlan>
|
||||
{
|
||||
new ()
|
||||
{
|
||||
PlanType = PlanType.TeamsMonthly,
|
||||
SeatMinimum = 100
|
||||
},
|
||||
new ()
|
||||
{
|
||||
PlanType = PlanType.EnterpriseMonthly,
|
||||
SeatMinimum = 100
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
|
||||
.Returns(providerPlans);
|
||||
|
||||
var teamsPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
||||
var enterprisePlan = StaticStore.GetPlan(PlanType.EnterpriseMonthly);
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
|
||||
sub =>
|
||||
sub.AutomaticTax.Enabled == true &&
|
||||
sub.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice &&
|
||||
sub.Customer == _customerId &&
|
||||
sub.DaysUntilDue == 30 &&
|
||||
sub.Items.Count == 2 &&
|
||||
sub.Items.ElementAt(0).Price == teamsPlan.PasswordManager.StripeSeatPlanId &&
|
||||
sub.Items.ElementAt(0).Quantity == 100 &&
|
||||
sub.Items.ElementAt(1).Price == enterprisePlan.PasswordManager.StripeSeatPlanId &&
|
||||
sub.Items.ElementAt(1).Quantity == 100 &&
|
||||
sub.Metadata["providerId"] == provider.Id.ToString() &&
|
||||
sub.OffSession == true &&
|
||||
sub.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations)).Returns(new Subscription
|
||||
{
|
||||
Id = _subscriptionId,
|
||||
Status = StripeConstants.SubscriptionStatus.Active
|
||||
});
|
||||
|
||||
await sutProvider.Sut.StartSubscription(provider, taxInfo);
|
||||
|
||||
await sutProvider.GetDependency<IProviderRepository>().Received(2).ReplaceAsync(provider);
|
||||
}
|
||||
#endregion
|
||||
|
||||
private static async Task DidNotCreateSubscriptionAsync(SutProvider<StartSubscriptionCommand> sutProvider) =>
|
||||
await sutProvider.GetDependency<IStripeAdapter>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>());
|
||||
|
||||
private static async Task DidNotRetrieveCustomerAsync(SutProvider<StartSubscriptionCommand> sutProvider) =>
|
||||
await sutProvider.GetDependency<IStripeAdapter>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.CustomerGetAsync(Arg.Any<string>(), Arg.Any<CustomerGetOptions>());
|
||||
|
||||
private static async Task DidNotRetrieveProviderPlansAsync(SutProvider<StartSubscriptionCommand> sutProvider) =>
|
||||
await sutProvider.GetDependency<IProviderPlanRepository>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.GetByProviderId(Arg.Any<Guid>());
|
||||
|
||||
private static void SetCustomerRetrieval(SutProvider<StartSubscriptionCommand> sutProvider,
|
||||
Customer customer) => sutProvider.GetDependency<IStripeAdapter>()
|
||||
.CustomerGetAsync(_customerId, Arg.Is<CustomerGetOptions>(o => o.Expand.FirstOrDefault() == "tax"))
|
||||
.Returns(customer);
|
||||
}
|
@ -1,154 +0,0 @@
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Entities;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Queries;
|
||||
using Bit.Core.Billing.Queries.Implementations;
|
||||
using Bit.Core.Billing.Repositories;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ReturnsExtensions;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Billing.Queries;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class ProviderBillingQueriesTests
|
||||
{
|
||||
#region GetSubscriptionData
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetSubscriptionData_NullProvider_ReturnsNull(
|
||||
SutProvider<ProviderBillingQueries> sutProvider,
|
||||
Guid providerId)
|
||||
{
|
||||
var providerRepository = sutProvider.GetDependency<IProviderRepository>();
|
||||
|
||||
providerRepository.GetByIdAsync(providerId).ReturnsNull();
|
||||
|
||||
var subscriptionData = await sutProvider.Sut.GetSubscriptionDTO(providerId);
|
||||
|
||||
Assert.Null(subscriptionData);
|
||||
|
||||
await providerRepository.Received(1).GetByIdAsync(providerId);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetSubscriptionData_NullSubscription_ReturnsNull(
|
||||
SutProvider<ProviderBillingQueries> sutProvider,
|
||||
Guid providerId,
|
||||
Provider provider)
|
||||
{
|
||||
var providerRepository = sutProvider.GetDependency<IProviderRepository>();
|
||||
|
||||
providerRepository.GetByIdAsync(providerId).Returns(provider);
|
||||
|
||||
var subscriberQueries = sutProvider.GetDependency<ISubscriberQueries>();
|
||||
|
||||
subscriberQueries.GetSubscription(provider).ReturnsNull();
|
||||
|
||||
var subscriptionData = await sutProvider.Sut.GetSubscriptionDTO(providerId);
|
||||
|
||||
Assert.Null(subscriptionData);
|
||||
|
||||
await providerRepository.Received(1).GetByIdAsync(providerId);
|
||||
|
||||
await subscriberQueries.Received(1).GetSubscription(
|
||||
provider,
|
||||
Arg.Is<SubscriptionGetOptions>(
|
||||
options => options.Expand.Count == 1 && options.Expand.First() == "customer"));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetSubscriptionData_Success(
|
||||
SutProvider<ProviderBillingQueries> sutProvider,
|
||||
Guid providerId,
|
||||
Provider provider)
|
||||
{
|
||||
var providerRepository = sutProvider.GetDependency<IProviderRepository>();
|
||||
|
||||
providerRepository.GetByIdAsync(providerId).Returns(provider);
|
||||
|
||||
var subscriberQueries = sutProvider.GetDependency<ISubscriberQueries>();
|
||||
|
||||
var subscription = new Subscription();
|
||||
|
||||
subscriberQueries.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(
|
||||
options => options.Expand.Count == 1 && options.Expand.First() == "customer")).Returns(subscription);
|
||||
|
||||
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
||||
|
||||
var enterprisePlan = new ProviderPlan
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProviderId = providerId,
|
||||
PlanType = PlanType.EnterpriseMonthly,
|
||||
SeatMinimum = 100,
|
||||
PurchasedSeats = 0,
|
||||
AllocatedSeats = 0
|
||||
};
|
||||
|
||||
var teamsPlan = new ProviderPlan
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProviderId = providerId,
|
||||
PlanType = PlanType.TeamsMonthly,
|
||||
SeatMinimum = 50,
|
||||
PurchasedSeats = 10,
|
||||
AllocatedSeats = 60
|
||||
};
|
||||
|
||||
var providerPlans = new List<ProviderPlan>
|
||||
{
|
||||
enterprisePlan,
|
||||
teamsPlan,
|
||||
};
|
||||
|
||||
providerPlanRepository.GetByProviderId(providerId).Returns(providerPlans);
|
||||
|
||||
var subscriptionData = await sutProvider.Sut.GetSubscriptionDTO(providerId);
|
||||
|
||||
Assert.NotNull(subscriptionData);
|
||||
|
||||
Assert.Equivalent(subscriptionData.Subscription, subscription);
|
||||
|
||||
Assert.Equal(2, subscriptionData.ProviderPlans.Count);
|
||||
|
||||
var configuredEnterprisePlan =
|
||||
subscriptionData.ProviderPlans.FirstOrDefault(configuredPlan =>
|
||||
configuredPlan.PlanType == PlanType.EnterpriseMonthly);
|
||||
|
||||
var configuredTeamsPlan =
|
||||
subscriptionData.ProviderPlans.FirstOrDefault(configuredPlan =>
|
||||
configuredPlan.PlanType == PlanType.TeamsMonthly);
|
||||
|
||||
Compare(enterprisePlan, configuredEnterprisePlan);
|
||||
|
||||
Compare(teamsPlan, configuredTeamsPlan);
|
||||
|
||||
await providerRepository.Received(1).GetByIdAsync(providerId);
|
||||
|
||||
await subscriberQueries.Received(1).GetSubscription(
|
||||
provider,
|
||||
Arg.Is<SubscriptionGetOptions>(
|
||||
options => options.Expand.Count == 1 && options.Expand.First() == "customer"));
|
||||
|
||||
await providerPlanRepository.Received(1).GetByProviderId(providerId);
|
||||
|
||||
return;
|
||||
|
||||
void Compare(ProviderPlan providerPlan, ConfiguredProviderPlanDTO configuredProviderPlan)
|
||||
{
|
||||
Assert.NotNull(configuredProviderPlan);
|
||||
Assert.Equal(providerPlan.Id, configuredProviderPlan.Id);
|
||||
Assert.Equal(providerPlan.ProviderId, configuredProviderPlan.ProviderId);
|
||||
Assert.Equal(providerPlan.SeatMinimum!.Value, configuredProviderPlan.SeatMinimum);
|
||||
Assert.Equal(providerPlan.PurchasedSeats!.Value, configuredProviderPlan.PurchasedSeats);
|
||||
Assert.Equal(providerPlan.AllocatedSeats!.Value, configuredProviderPlan.AssignedSeats);
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
@ -1,272 +0,0 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Queries.Implementations;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using NSubstitute.ReturnsExtensions;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
|
||||
using static Bit.Core.Test.Billing.Utilities;
|
||||
|
||||
namespace Bit.Core.Test.Billing.Queries;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class SubscriberQueriesTests
|
||||
{
|
||||
#region GetCustomer
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetCustomer_NullSubscriber_ThrowsArgumentNullException(
|
||||
SutProvider<SubscriberQueries> sutProvider)
|
||||
=> await Assert.ThrowsAsync<ArgumentNullException>(
|
||||
async () => await sutProvider.Sut.GetCustomer(null));
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetCustomer_NoGatewayCustomerId_ReturnsNull(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberQueries> sutProvider)
|
||||
{
|
||||
organization.GatewayCustomerId = null;
|
||||
|
||||
var customer = await sutProvider.Sut.GetCustomer(organization);
|
||||
|
||||
Assert.Null(customer);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetCustomer_NoCustomer_ReturnsNull(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberQueries> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.CustomerGetAsync(organization.GatewayCustomerId)
|
||||
.ReturnsNull();
|
||||
|
||||
var customer = await sutProvider.Sut.GetCustomer(organization);
|
||||
|
||||
Assert.Null(customer);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetCustomer_StripeException_ReturnsNull(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberQueries> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.CustomerGetAsync(organization.GatewayCustomerId)
|
||||
.ThrowsAsync<StripeException>();
|
||||
|
||||
var customer = await sutProvider.Sut.GetCustomer(organization);
|
||||
|
||||
Assert.Null(customer);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetCustomer_Succeeds(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberQueries> sutProvider)
|
||||
{
|
||||
var customer = new Customer();
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.CustomerGetAsync(organization.GatewayCustomerId)
|
||||
.Returns(customer);
|
||||
|
||||
var gotCustomer = await sutProvider.Sut.GetCustomer(organization);
|
||||
|
||||
Assert.Equivalent(customer, gotCustomer);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region GetSubscription
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetSubscription_NullSubscriber_ThrowsArgumentNullException(
|
||||
SutProvider<SubscriberQueries> sutProvider)
|
||||
=> await Assert.ThrowsAsync<ArgumentNullException>(
|
||||
async () => await sutProvider.Sut.GetSubscription(null));
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetSubscription_NoGatewaySubscriptionId_ReturnsNull(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberQueries> sutProvider)
|
||||
{
|
||||
organization.GatewaySubscriptionId = null;
|
||||
|
||||
var subscription = await sutProvider.Sut.GetSubscription(organization);
|
||||
|
||||
Assert.Null(subscription);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetSubscription_NoSubscription_ReturnsNull(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberQueries> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.SubscriptionGetAsync(organization.GatewaySubscriptionId)
|
||||
.ReturnsNull();
|
||||
|
||||
var subscription = await sutProvider.Sut.GetSubscription(organization);
|
||||
|
||||
Assert.Null(subscription);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetSubscription_StripeException_ReturnsNull(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberQueries> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.SubscriptionGetAsync(organization.GatewaySubscriptionId)
|
||||
.ThrowsAsync<StripeException>();
|
||||
|
||||
var subscription = await sutProvider.Sut.GetSubscription(organization);
|
||||
|
||||
Assert.Null(subscription);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetSubscription_Succeeds(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberQueries> sutProvider)
|
||||
{
|
||||
var subscription = new Subscription();
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.SubscriptionGetAsync(organization.GatewaySubscriptionId)
|
||||
.Returns(subscription);
|
||||
|
||||
var gotSubscription = await sutProvider.Sut.GetSubscription(organization);
|
||||
|
||||
Assert.Equivalent(subscription, gotSubscription);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region GetCustomerOrThrow
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetCustomerOrThrow_NullSubscriber_ThrowsArgumentNullException(
|
||||
SutProvider<SubscriberQueries> sutProvider)
|
||||
=> await Assert.ThrowsAsync<ArgumentNullException>(
|
||||
async () => await sutProvider.Sut.GetCustomerOrThrow(null));
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetCustomerOrThrow_NoGatewayCustomerId_ContactSupport(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberQueries> sutProvider)
|
||||
{
|
||||
organization.GatewayCustomerId = null;
|
||||
|
||||
await ThrowsContactSupportAsync(async () => await sutProvider.Sut.GetCustomerOrThrow(organization));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetCustomerOrThrow_NoCustomer_ContactSupport(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberQueries> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.CustomerGetAsync(organization.GatewayCustomerId)
|
||||
.ReturnsNull();
|
||||
|
||||
await ThrowsContactSupportAsync(async () => await sutProvider.Sut.GetCustomerOrThrow(organization));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetCustomerOrThrow_StripeException_ContactSupport(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberQueries> sutProvider)
|
||||
{
|
||||
var stripeException = new StripeException();
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.CustomerGetAsync(organization.GatewayCustomerId)
|
||||
.ThrowsAsync(stripeException);
|
||||
|
||||
await ThrowsContactSupportAsync(
|
||||
async () => await sutProvider.Sut.GetCustomerOrThrow(organization),
|
||||
"An error occurred while trying to retrieve a Stripe Customer",
|
||||
stripeException);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetCustomerOrThrow_Succeeds(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberQueries> sutProvider)
|
||||
{
|
||||
var customer = new Customer();
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.CustomerGetAsync(organization.GatewayCustomerId)
|
||||
.Returns(customer);
|
||||
|
||||
var gotCustomer = await sutProvider.Sut.GetCustomerOrThrow(organization);
|
||||
|
||||
Assert.Equivalent(customer, gotCustomer);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region GetSubscriptionOrThrow
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetSubscriptionOrThrow_NullSubscriber_ThrowsArgumentNullException(
|
||||
SutProvider<SubscriberQueries> sutProvider)
|
||||
=> await Assert.ThrowsAsync<ArgumentNullException>(
|
||||
async () => await sutProvider.Sut.GetSubscriptionOrThrow(null));
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetSubscriptionOrThrow_NoGatewaySubscriptionId_ContactSupport(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberQueries> sutProvider)
|
||||
{
|
||||
organization.GatewaySubscriptionId = null;
|
||||
|
||||
await ThrowsContactSupportAsync(async () => await sutProvider.Sut.GetSubscriptionOrThrow(organization));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetSubscriptionOrThrow_NoSubscription_ContactSupport(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberQueries> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.SubscriptionGetAsync(organization.GatewaySubscriptionId)
|
||||
.ReturnsNull();
|
||||
|
||||
await ThrowsContactSupportAsync(async () => await sutProvider.Sut.GetSubscriptionOrThrow(organization));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetSubscriptionOrThrow_StripeException_ContactSupport(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberQueries> sutProvider)
|
||||
{
|
||||
var stripeException = new StripeException();
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.SubscriptionGetAsync(organization.GatewaySubscriptionId)
|
||||
.ThrowsAsync(stripeException);
|
||||
|
||||
await ThrowsContactSupportAsync(
|
||||
async () => await sutProvider.Sut.GetSubscriptionOrThrow(organization),
|
||||
"An error occurred while trying to retrieve a Stripe Subscription",
|
||||
stripeException);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetSubscriptionOrThrow_Succeeds(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberQueries> sutProvider)
|
||||
{
|
||||
var subscription = new Subscription();
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.SubscriptionGetAsync(organization.GatewaySubscriptionId)
|
||||
.Returns(subscription);
|
||||
|
||||
var gotSubscription = await sutProvider.Sut.GetSubscriptionOrThrow(organization);
|
||||
|
||||
Assert.Equivalent(subscription, gotSubscription);
|
||||
}
|
||||
#endregion
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Queries;
|
||||
using Bit.Core.Billing.Queries.Implementations;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Billing.Services.Implementations;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
@ -9,16 +9,16 @@ using NSubstitute;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Billing.Queries;
|
||||
namespace Bit.Core.Test.Billing.Services;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class OrganizationBillingQueriesTests
|
||||
public class OrganizationBillingServiceTests
|
||||
{
|
||||
#region GetMetadata
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetMetadata_OrganizationNull_ReturnsNull(
|
||||
Guid organizationId,
|
||||
SutProvider<OrganizationBillingQueries> sutProvider)
|
||||
SutProvider<OrganizationBillingService> sutProvider)
|
||||
{
|
||||
var metadata = await sutProvider.Sut.GetMetadata(organizationId);
|
||||
|
||||
@ -29,7 +29,7 @@ public class OrganizationBillingQueriesTests
|
||||
public async Task GetMetadata_CustomerNull_ReturnsNull(
|
||||
Guid organizationId,
|
||||
Organization organization,
|
||||
SutProvider<OrganizationBillingQueries> sutProvider)
|
||||
SutProvider<OrganizationBillingService> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
|
||||
|
||||
@ -42,11 +42,11 @@ public class OrganizationBillingQueriesTests
|
||||
public async Task GetMetadata_SubscriptionNull_ReturnsNull(
|
||||
Guid organizationId,
|
||||
Organization organization,
|
||||
SutProvider<OrganizationBillingQueries> sutProvider)
|
||||
SutProvider<OrganizationBillingService> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
|
||||
|
||||
sutProvider.GetDependency<ISubscriberQueries>().GetCustomer(organization).Returns(new Customer());
|
||||
sutProvider.GetDependency<ISubscriberService>().GetCustomer(organization).Returns(new Customer());
|
||||
|
||||
var metadata = await sutProvider.Sut.GetMetadata(organizationId);
|
||||
|
||||
@ -57,13 +57,13 @@ public class OrganizationBillingQueriesTests
|
||||
public async Task GetMetadata_Succeeds(
|
||||
Guid organizationId,
|
||||
Organization organization,
|
||||
SutProvider<OrganizationBillingQueries> sutProvider)
|
||||
SutProvider<OrganizationBillingService> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
|
||||
|
||||
var subscriberQueries = sutProvider.GetDependency<ISubscriberQueries>();
|
||||
var subscriberService = sutProvider.GetDependency<ISubscriberService>();
|
||||
|
||||
subscriberQueries
|
||||
subscriberService
|
||||
.GetCustomer(organization, Arg.Is<CustomerGetOptions>(options => options.Expand.FirstOrDefault() == "discount.coupon.applies_to"))
|
||||
.Returns(new Customer
|
||||
{
|
||||
@ -80,7 +80,7 @@ public class OrganizationBillingQueriesTests
|
||||
}
|
||||
});
|
||||
|
||||
subscriberQueries.GetSubscription(organization).Returns(new Subscription
|
||||
subscriberService.GetSubscription(organization).Returns(new Subscription
|
||||
{
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
881
test/Core.Test/Billing/Services/SubscriberServiceTests.cs
Normal file
881
test/Core.Test/Billing/Services/SubscriberServiceTests.cs
Normal file
@ -0,0 +1,881 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.Billing;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Services.Implementations;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Braintree;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using NSubstitute.ReturnsExtensions;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
|
||||
using static Bit.Core.Test.Billing.Utilities;
|
||||
using Customer = Stripe.Customer;
|
||||
using PaymentMethod = Stripe.PaymentMethod;
|
||||
using Subscription = Stripe.Subscription;
|
||||
|
||||
namespace Bit.Core.Test.Billing.Services;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class SubscriberServiceTests
|
||||
{
|
||||
#region CancelSubscription
|
||||
[Theory, BitAutoData]
|
||||
public async Task CancelSubscription_SubscriptionInactive_ContactSupport(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
{
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Status = "canceled"
|
||||
};
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
stripeAdapter
|
||||
.SubscriptionGetAsync(organization.GatewaySubscriptionId)
|
||||
.Returns(subscription);
|
||||
|
||||
await ThrowsContactSupportAsync(() =>
|
||||
sutProvider.Sut.CancelSubscription(organization, new OffboardingSurveyResponse(), false));
|
||||
|
||||
await stripeAdapter
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.SubscriptionUpdateAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
|
||||
|
||||
await stripeAdapter
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.SubscriptionCancelAsync(Arg.Any<string>(), Arg.Any<SubscriptionCancelOptions>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CancelSubscription_CancelImmediately_BelongsToOrganization_UpdatesSubscription_CancelSubscriptionImmediately(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
|
||||
const string subscriptionId = "subscription_id";
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = subscriptionId,
|
||||
Status = "active",
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "organizationId", "organization_id" }
|
||||
}
|
||||
};
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
stripeAdapter
|
||||
.SubscriptionGetAsync(organization.GatewaySubscriptionId)
|
||||
.Returns(subscription);
|
||||
|
||||
var offboardingSurveyResponse = new OffboardingSurveyResponse
|
||||
{
|
||||
UserId = userId,
|
||||
Reason = "missing_features",
|
||||
Feedback = "Lorem ipsum"
|
||||
};
|
||||
|
||||
await sutProvider.Sut.CancelSubscription(organization, offboardingSurveyResponse, true);
|
||||
|
||||
await stripeAdapter
|
||||
.Received(1)
|
||||
.SubscriptionUpdateAsync(subscriptionId, Arg.Is<SubscriptionUpdateOptions>(
|
||||
options => options.Metadata["cancellingUserId"] == userId.ToString()));
|
||||
|
||||
await stripeAdapter
|
||||
.Received(1)
|
||||
.SubscriptionCancelAsync(subscriptionId, Arg.Is<SubscriptionCancelOptions>(options =>
|
||||
options.CancellationDetails.Comment == offboardingSurveyResponse.Feedback &&
|
||||
options.CancellationDetails.Feedback == offboardingSurveyResponse.Reason));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CancelSubscription_CancelImmediately_BelongsToUser_CancelSubscriptionImmediately(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
|
||||
const string subscriptionId = "subscription_id";
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = subscriptionId,
|
||||
Status = "active",
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "userId", "user_id" }
|
||||
}
|
||||
};
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
stripeAdapter
|
||||
.SubscriptionGetAsync(organization.GatewaySubscriptionId)
|
||||
.Returns(subscription);
|
||||
|
||||
var offboardingSurveyResponse = new OffboardingSurveyResponse
|
||||
{
|
||||
UserId = userId,
|
||||
Reason = "missing_features",
|
||||
Feedback = "Lorem ipsum"
|
||||
};
|
||||
|
||||
await sutProvider.Sut.CancelSubscription(organization, offboardingSurveyResponse, true);
|
||||
|
||||
await stripeAdapter
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.SubscriptionUpdateAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
|
||||
|
||||
await stripeAdapter
|
||||
.Received(1)
|
||||
.SubscriptionCancelAsync(subscriptionId, Arg.Is<SubscriptionCancelOptions>(options =>
|
||||
options.CancellationDetails.Comment == offboardingSurveyResponse.Feedback &&
|
||||
options.CancellationDetails.Feedback == offboardingSurveyResponse.Reason));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CancelSubscription_DoNotCancelImmediately_UpdateSubscriptionToCancelAtEndOfPeriod(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
|
||||
const string subscriptionId = "subscription_id";
|
||||
|
||||
organization.ExpirationDate = DateTime.UtcNow.AddDays(5);
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = subscriptionId,
|
||||
Status = "active"
|
||||
};
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
stripeAdapter
|
||||
.SubscriptionGetAsync(organization.GatewaySubscriptionId)
|
||||
.Returns(subscription);
|
||||
|
||||
var offboardingSurveyResponse = new OffboardingSurveyResponse
|
||||
{
|
||||
UserId = userId,
|
||||
Reason = "missing_features",
|
||||
Feedback = "Lorem ipsum"
|
||||
};
|
||||
|
||||
await sutProvider.Sut.CancelSubscription(organization, offboardingSurveyResponse, false);
|
||||
|
||||
await stripeAdapter
|
||||
.Received(1)
|
||||
.SubscriptionUpdateAsync(subscriptionId, Arg.Is<SubscriptionUpdateOptions>(options =>
|
||||
options.CancelAtPeriodEnd == true &&
|
||||
options.CancellationDetails.Comment == offboardingSurveyResponse.Feedback &&
|
||||
options.CancellationDetails.Feedback == offboardingSurveyResponse.Reason &&
|
||||
options.Metadata["cancellingUserId"] == userId.ToString()));
|
||||
|
||||
await stripeAdapter
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.SubscriptionCancelAsync(Arg.Any<string>(), Arg.Any<SubscriptionCancelOptions>()); ;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region GetCustomer
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetCustomer_NullSubscriber_ThrowsArgumentNullException(
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
=> await Assert.ThrowsAsync<ArgumentNullException>(
|
||||
async () => await sutProvider.Sut.GetCustomer(null));
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetCustomer_NoGatewayCustomerId_ReturnsNull(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
{
|
||||
organization.GatewayCustomerId = null;
|
||||
|
||||
var customer = await sutProvider.Sut.GetCustomer(organization);
|
||||
|
||||
Assert.Null(customer);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetCustomer_NoCustomer_ReturnsNull(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.CustomerGetAsync(organization.GatewayCustomerId)
|
||||
.ReturnsNull();
|
||||
|
||||
var customer = await sutProvider.Sut.GetCustomer(organization);
|
||||
|
||||
Assert.Null(customer);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetCustomer_StripeException_ReturnsNull(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.CustomerGetAsync(organization.GatewayCustomerId)
|
||||
.ThrowsAsync<StripeException>();
|
||||
|
||||
var customer = await sutProvider.Sut.GetCustomer(organization);
|
||||
|
||||
Assert.Null(customer);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetCustomer_Succeeds(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
{
|
||||
var customer = new Customer();
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.CustomerGetAsync(organization.GatewayCustomerId)
|
||||
.Returns(customer);
|
||||
|
||||
var gotCustomer = await sutProvider.Sut.GetCustomer(organization);
|
||||
|
||||
Assert.Equivalent(customer, gotCustomer);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region GetCustomerOrThrow
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetCustomerOrThrow_NullSubscriber_ThrowsArgumentNullException(
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
=> await Assert.ThrowsAsync<ArgumentNullException>(
|
||||
async () => await sutProvider.Sut.GetCustomerOrThrow(null));
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetCustomerOrThrow_NoGatewayCustomerId_ContactSupport(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
{
|
||||
organization.GatewayCustomerId = null;
|
||||
|
||||
await ThrowsContactSupportAsync(async () => await sutProvider.Sut.GetCustomerOrThrow(organization));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetCustomerOrThrow_NoCustomer_ContactSupport(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.CustomerGetAsync(organization.GatewayCustomerId)
|
||||
.ReturnsNull();
|
||||
|
||||
await ThrowsContactSupportAsync(async () => await sutProvider.Sut.GetCustomerOrThrow(organization));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetCustomerOrThrow_StripeException_ContactSupport(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
{
|
||||
var stripeException = new StripeException();
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.CustomerGetAsync(organization.GatewayCustomerId)
|
||||
.ThrowsAsync(stripeException);
|
||||
|
||||
await ThrowsContactSupportAsync(
|
||||
async () => await sutProvider.Sut.GetCustomerOrThrow(organization),
|
||||
"An error occurred while trying to retrieve a Stripe Customer",
|
||||
stripeException);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetCustomerOrThrow_Succeeds(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
{
|
||||
var customer = new Customer();
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.CustomerGetAsync(organization.GatewayCustomerId)
|
||||
.Returns(customer);
|
||||
|
||||
var gotCustomer = await sutProvider.Sut.GetCustomerOrThrow(organization);
|
||||
|
||||
Assert.Equivalent(customer, gotCustomer);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region GetSubscription
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetSubscription_NullSubscriber_ThrowsArgumentNullException(
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
=> await Assert.ThrowsAsync<ArgumentNullException>(
|
||||
async () => await sutProvider.Sut.GetSubscription(null));
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetSubscription_NoGatewaySubscriptionId_ReturnsNull(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
{
|
||||
organization.GatewaySubscriptionId = null;
|
||||
|
||||
var subscription = await sutProvider.Sut.GetSubscription(organization);
|
||||
|
||||
Assert.Null(subscription);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetSubscription_NoSubscription_ReturnsNull(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.SubscriptionGetAsync(organization.GatewaySubscriptionId)
|
||||
.ReturnsNull();
|
||||
|
||||
var subscription = await sutProvider.Sut.GetSubscription(organization);
|
||||
|
||||
Assert.Null(subscription);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetSubscription_StripeException_ReturnsNull(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.SubscriptionGetAsync(organization.GatewaySubscriptionId)
|
||||
.ThrowsAsync<StripeException>();
|
||||
|
||||
var subscription = await sutProvider.Sut.GetSubscription(organization);
|
||||
|
||||
Assert.Null(subscription);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetSubscription_Succeeds(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
{
|
||||
var subscription = new Subscription();
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.SubscriptionGetAsync(organization.GatewaySubscriptionId)
|
||||
.Returns(subscription);
|
||||
|
||||
var gotSubscription = await sutProvider.Sut.GetSubscription(organization);
|
||||
|
||||
Assert.Equivalent(subscription, gotSubscription);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region GetSubscriptionOrThrow
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetSubscriptionOrThrow_NullSubscriber_ThrowsArgumentNullException(
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
=> await Assert.ThrowsAsync<ArgumentNullException>(
|
||||
async () => await sutProvider.Sut.GetSubscriptionOrThrow(null));
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetSubscriptionOrThrow_NoGatewaySubscriptionId_ContactSupport(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
{
|
||||
organization.GatewaySubscriptionId = null;
|
||||
|
||||
await ThrowsContactSupportAsync(async () => await sutProvider.Sut.GetSubscriptionOrThrow(organization));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetSubscriptionOrThrow_NoSubscription_ContactSupport(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.SubscriptionGetAsync(organization.GatewaySubscriptionId)
|
||||
.ReturnsNull();
|
||||
|
||||
await ThrowsContactSupportAsync(async () => await sutProvider.Sut.GetSubscriptionOrThrow(organization));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetSubscriptionOrThrow_StripeException_ContactSupport(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
{
|
||||
var stripeException = new StripeException();
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.SubscriptionGetAsync(organization.GatewaySubscriptionId)
|
||||
.ThrowsAsync(stripeException);
|
||||
|
||||
await ThrowsContactSupportAsync(
|
||||
async () => await sutProvider.Sut.GetSubscriptionOrThrow(organization),
|
||||
"An error occurred while trying to retrieve a Stripe Subscription",
|
||||
stripeException);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetSubscriptionOrThrow_Succeeds(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
{
|
||||
var subscription = new Subscription();
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.SubscriptionGetAsync(organization.GatewaySubscriptionId)
|
||||
.Returns(subscription);
|
||||
|
||||
var gotSubscription = await sutProvider.Sut.GetSubscriptionOrThrow(organization);
|
||||
|
||||
Assert.Equivalent(subscription, gotSubscription);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region RemovePaymentMethod
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemovePaymentMethod_NullSubscriber_ArgumentNullException(
|
||||
SutProvider<SubscriberService> sutProvider) =>
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => sutProvider.Sut.RemovePaymentMethod(null));
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemovePaymentMethod_Braintree_NoCustomer_ContactSupport(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
{
|
||||
const string braintreeCustomerId = "1";
|
||||
|
||||
var stripeCustomer = new Customer
|
||||
{
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "btCustomerId", braintreeCustomerId }
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.CustomerGetAsync(organization.GatewayCustomerId, Arg.Any<CustomerGetOptions>())
|
||||
.Returns(stripeCustomer);
|
||||
|
||||
var (braintreeGateway, customerGateway, paymentMethodGateway) = SetupBraintree(sutProvider.GetDependency<IBraintreeGateway>());
|
||||
|
||||
customerGateway.FindAsync(braintreeCustomerId).ReturnsNull();
|
||||
|
||||
braintreeGateway.Customer.Returns(customerGateway);
|
||||
|
||||
await ThrowsContactSupportAsync(() => sutProvider.Sut.RemovePaymentMethod(organization));
|
||||
|
||||
await customerGateway.Received(1).FindAsync(braintreeCustomerId);
|
||||
|
||||
await customerGateway.DidNotReceiveWithAnyArgs()
|
||||
.UpdateAsync(Arg.Any<string>(), Arg.Any<CustomerRequest>());
|
||||
|
||||
await paymentMethodGateway.DidNotReceiveWithAnyArgs().DeleteAsync(Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemovePaymentMethod_Braintree_NoPaymentMethod_NoOp(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
{
|
||||
const string braintreeCustomerId = "1";
|
||||
|
||||
var stripeCustomer = new Customer
|
||||
{
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "btCustomerId", braintreeCustomerId }
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.CustomerGetAsync(organization.GatewayCustomerId, Arg.Any<CustomerGetOptions>())
|
||||
.Returns(stripeCustomer);
|
||||
|
||||
var (_, customerGateway, paymentMethodGateway) = SetupBraintree(sutProvider.GetDependency<IBraintreeGateway>());
|
||||
|
||||
var braintreeCustomer = Substitute.For<Braintree.Customer>();
|
||||
|
||||
braintreeCustomer.PaymentMethods.Returns([]);
|
||||
|
||||
customerGateway.FindAsync(braintreeCustomerId).Returns(braintreeCustomer);
|
||||
|
||||
await sutProvider.Sut.RemovePaymentMethod(organization);
|
||||
|
||||
await customerGateway.Received(1).FindAsync(braintreeCustomerId);
|
||||
|
||||
await customerGateway.DidNotReceiveWithAnyArgs().UpdateAsync(Arg.Any<string>(), Arg.Any<CustomerRequest>());
|
||||
|
||||
await paymentMethodGateway.DidNotReceiveWithAnyArgs().DeleteAsync(Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemovePaymentMethod_Braintree_CustomerUpdateFails_ContactSupport(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
{
|
||||
const string braintreeCustomerId = "1";
|
||||
const string braintreePaymentMethodToken = "TOKEN";
|
||||
|
||||
var stripeCustomer = new Customer
|
||||
{
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "btCustomerId", braintreeCustomerId }
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.CustomerGetAsync(organization.GatewayCustomerId, Arg.Any<CustomerGetOptions>())
|
||||
.Returns(stripeCustomer);
|
||||
|
||||
var (_, customerGateway, paymentMethodGateway) = SetupBraintree(sutProvider.GetDependency<IBraintreeGateway>());
|
||||
|
||||
var braintreeCustomer = Substitute.For<Braintree.Customer>();
|
||||
|
||||
var paymentMethod = Substitute.For<Braintree.PaymentMethod>();
|
||||
paymentMethod.Token.Returns(braintreePaymentMethodToken);
|
||||
paymentMethod.IsDefault.Returns(true);
|
||||
|
||||
braintreeCustomer.PaymentMethods.Returns([
|
||||
paymentMethod
|
||||
]);
|
||||
|
||||
customerGateway.FindAsync(braintreeCustomerId).Returns(braintreeCustomer);
|
||||
|
||||
var updateBraintreeCustomerResult = Substitute.For<Result<Braintree.Customer>>();
|
||||
updateBraintreeCustomerResult.IsSuccess().Returns(false);
|
||||
|
||||
customerGateway.UpdateAsync(
|
||||
braintreeCustomerId,
|
||||
Arg.Is<CustomerRequest>(request => request.DefaultPaymentMethodToken == null))
|
||||
.Returns(updateBraintreeCustomerResult);
|
||||
|
||||
await ThrowsContactSupportAsync(() => sutProvider.Sut.RemovePaymentMethod(organization));
|
||||
|
||||
await customerGateway.Received(1).FindAsync(braintreeCustomerId);
|
||||
|
||||
await customerGateway.Received(1).UpdateAsync(braintreeCustomerId, Arg.Is<CustomerRequest>(request =>
|
||||
request.DefaultPaymentMethodToken == null));
|
||||
|
||||
await paymentMethodGateway.DidNotReceiveWithAnyArgs().DeleteAsync(paymentMethod.Token);
|
||||
|
||||
await customerGateway.DidNotReceive().UpdateAsync(braintreeCustomerId, Arg.Is<CustomerRequest>(request =>
|
||||
request.DefaultPaymentMethodToken == paymentMethod.Token));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemovePaymentMethod_Braintree_PaymentMethodDeleteFails_RollBack_ContactSupport(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
{
|
||||
const string braintreeCustomerId = "1";
|
||||
const string braintreePaymentMethodToken = "TOKEN";
|
||||
|
||||
var stripeCustomer = new Customer
|
||||
{
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "btCustomerId", braintreeCustomerId }
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.CustomerGetAsync(organization.GatewayCustomerId, Arg.Any<CustomerGetOptions>())
|
||||
.Returns(stripeCustomer);
|
||||
|
||||
var (_, customerGateway, paymentMethodGateway) = SetupBraintree(sutProvider.GetDependency<IBraintreeGateway>());
|
||||
|
||||
var braintreeCustomer = Substitute.For<Braintree.Customer>();
|
||||
|
||||
var paymentMethod = Substitute.For<Braintree.PaymentMethod>();
|
||||
paymentMethod.Token.Returns(braintreePaymentMethodToken);
|
||||
paymentMethod.IsDefault.Returns(true);
|
||||
|
||||
braintreeCustomer.PaymentMethods.Returns([
|
||||
paymentMethod
|
||||
]);
|
||||
|
||||
customerGateway.FindAsync(braintreeCustomerId).Returns(braintreeCustomer);
|
||||
|
||||
var updateBraintreeCustomerResult = Substitute.For<Result<Braintree.Customer>>();
|
||||
updateBraintreeCustomerResult.IsSuccess().Returns(true);
|
||||
|
||||
customerGateway.UpdateAsync(braintreeCustomerId, Arg.Any<CustomerRequest>())
|
||||
.Returns(updateBraintreeCustomerResult);
|
||||
|
||||
var deleteBraintreePaymentMethodResult = Substitute.For<Result<Braintree.PaymentMethod>>();
|
||||
deleteBraintreePaymentMethodResult.IsSuccess().Returns(false);
|
||||
|
||||
paymentMethodGateway.DeleteAsync(paymentMethod.Token).Returns(deleteBraintreePaymentMethodResult);
|
||||
|
||||
await ThrowsContactSupportAsync(() => sutProvider.Sut.RemovePaymentMethod(organization));
|
||||
|
||||
await customerGateway.Received(1).FindAsync(braintreeCustomerId);
|
||||
|
||||
await customerGateway.Received(1).UpdateAsync(braintreeCustomerId, Arg.Is<CustomerRequest>(request =>
|
||||
request.DefaultPaymentMethodToken == null));
|
||||
|
||||
await paymentMethodGateway.Received(1).DeleteAsync(paymentMethod.Token);
|
||||
|
||||
await customerGateway.Received(1).UpdateAsync(braintreeCustomerId, Arg.Is<CustomerRequest>(request =>
|
||||
request.DefaultPaymentMethodToken == paymentMethod.Token));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemovePaymentMethod_Stripe_Legacy_RemovesSources(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
{
|
||||
const string bankAccountId = "bank_account_id";
|
||||
const string cardId = "card_id";
|
||||
|
||||
var sources = new List<IPaymentSource>
|
||||
{
|
||||
new BankAccount { Id = bankAccountId }, new Card { Id = cardId }
|
||||
};
|
||||
|
||||
var stripeCustomer = new Customer { Sources = new StripeList<IPaymentSource> { Data = sources } };
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
stripeAdapter
|
||||
.CustomerGetAsync(organization.GatewayCustomerId, Arg.Any<CustomerGetOptions>())
|
||||
.Returns(stripeCustomer);
|
||||
|
||||
stripeAdapter
|
||||
.PaymentMethodListAutoPagingAsync(Arg.Any<PaymentMethodListOptions>())
|
||||
.Returns(GetPaymentMethodsAsync(new List<Stripe.PaymentMethod>()));
|
||||
|
||||
await sutProvider.Sut.RemovePaymentMethod(organization);
|
||||
|
||||
await stripeAdapter.Received(1).BankAccountDeleteAsync(stripeCustomer.Id, bankAccountId);
|
||||
|
||||
await stripeAdapter.Received(1).CardDeleteAsync(stripeCustomer.Id, cardId);
|
||||
|
||||
await stripeAdapter.DidNotReceiveWithAnyArgs()
|
||||
.PaymentMethodDetachAsync(Arg.Any<string>(), Arg.Any<PaymentMethodDetachOptions>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemovePaymentMethod_Stripe_DetachesPaymentMethods(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
{
|
||||
const string bankAccountId = "bank_account_id";
|
||||
const string cardId = "card_id";
|
||||
|
||||
var sources = new List<IPaymentSource>();
|
||||
|
||||
var stripeCustomer = new Customer { Sources = new StripeList<IPaymentSource> { Data = sources } };
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
stripeAdapter
|
||||
.CustomerGetAsync(organization.GatewayCustomerId, Arg.Any<CustomerGetOptions>())
|
||||
.Returns(stripeCustomer);
|
||||
|
||||
stripeAdapter
|
||||
.PaymentMethodListAutoPagingAsync(Arg.Any<PaymentMethodListOptions>())
|
||||
.Returns(GetPaymentMethodsAsync(new List<Stripe.PaymentMethod>
|
||||
{
|
||||
new ()
|
||||
{
|
||||
Id = bankAccountId
|
||||
},
|
||||
new ()
|
||||
{
|
||||
Id = cardId
|
||||
}
|
||||
}));
|
||||
|
||||
await sutProvider.Sut.RemovePaymentMethod(organization);
|
||||
|
||||
await stripeAdapter.DidNotReceiveWithAnyArgs().BankAccountDeleteAsync(Arg.Any<string>(), Arg.Any<string>());
|
||||
|
||||
await stripeAdapter.DidNotReceiveWithAnyArgs().CardDeleteAsync(Arg.Any<string>(), Arg.Any<string>());
|
||||
|
||||
await stripeAdapter.Received(1)
|
||||
.PaymentMethodDetachAsync(bankAccountId);
|
||||
|
||||
await stripeAdapter.Received(1)
|
||||
.PaymentMethodDetachAsync(cardId);
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<Stripe.PaymentMethod> GetPaymentMethodsAsync(
|
||||
IEnumerable<Stripe.PaymentMethod> paymentMethods)
|
||||
{
|
||||
foreach (var paymentMethod in paymentMethods)
|
||||
{
|
||||
yield return paymentMethod;
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static (IBraintreeGateway, ICustomerGateway, IPaymentMethodGateway) SetupBraintree(
|
||||
IBraintreeGateway braintreeGateway)
|
||||
{
|
||||
var customerGateway = Substitute.For<ICustomerGateway>();
|
||||
var paymentMethodGateway = Substitute.For<IPaymentMethodGateway>();
|
||||
|
||||
braintreeGateway.Customer.Returns(customerGateway);
|
||||
braintreeGateway.PaymentMethod.Returns(paymentMethodGateway);
|
||||
|
||||
return (braintreeGateway, customerGateway, paymentMethodGateway);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region GetTaxInformationAsync
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetTaxInformationAsync_NullSubscriber_ThrowsArgumentNullException(
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
=> await Assert.ThrowsAsync<ArgumentNullException>(
|
||||
async () => await sutProvider.Sut.GetTaxInformationAsync(null));
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetTaxInformationAsync_NoGatewayCustomerId_ReturnsNull(
|
||||
Provider subscriber,
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
{
|
||||
subscriber.GatewayCustomerId = null;
|
||||
|
||||
var taxInfo = await sutProvider.Sut.GetTaxInformationAsync(subscriber);
|
||||
|
||||
Assert.Null(taxInfo);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetTaxInformationAsync_NoCustomer_ReturnsNull(
|
||||
Provider subscriber,
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.CustomerGetAsync(subscriber.GatewayCustomerId, Arg.Any<CustomerGetOptions>())
|
||||
.Returns((Customer)null);
|
||||
|
||||
await Assert.ThrowsAsync<BillingException>(
|
||||
() => sutProvider.Sut.GetTaxInformationAsync(subscriber));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetTaxInformationAsync_StripeException_ReturnsNull(
|
||||
Provider subscriber,
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.CustomerGetAsync(subscriber.GatewayCustomerId, Arg.Any<CustomerGetOptions>())
|
||||
.ThrowsAsync(new StripeException());
|
||||
|
||||
await Assert.ThrowsAsync<BillingException>(
|
||||
() => sutProvider.Sut.GetTaxInformationAsync(subscriber));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetTaxInformationAsync_Succeeds(
|
||||
Provider subscriber,
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
{
|
||||
var customer = new Customer
|
||||
{
|
||||
Address = new Stripe.Address
|
||||
{
|
||||
Line1 = "123 Main St",
|
||||
Line2 = "Apt 4B",
|
||||
City = "Metropolis",
|
||||
State = "NY",
|
||||
PostalCode = "12345",
|
||||
Country = "US"
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.CustomerGetAsync(subscriber.GatewayCustomerId, Arg.Any<CustomerGetOptions>())
|
||||
.Returns(customer);
|
||||
|
||||
var taxInfo = await sutProvider.Sut.GetTaxInformationAsync(subscriber);
|
||||
|
||||
Assert.NotNull(taxInfo);
|
||||
Assert.Equal("123 Main St", taxInfo.BillingAddressLine1);
|
||||
Assert.Equal("Apt 4B", taxInfo.BillingAddressLine2);
|
||||
Assert.Equal("Metropolis", taxInfo.BillingAddressCity);
|
||||
Assert.Equal("NY", taxInfo.BillingAddressState);
|
||||
Assert.Equal("12345", taxInfo.BillingAddressPostalCode);
|
||||
Assert.Equal("US", taxInfo.BillingAddressCountry);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region GetPaymentMethodAsync
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetPaymentMethodAsync_NullSubscriber_ThrowsArgumentNullException(
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
{
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(
|
||||
async () => await sutProvider.Sut.GetPaymentMethodAsync(null));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetPaymentMethodAsync_NoCustomer_ReturnsNull(
|
||||
Provider subscriber,
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
{
|
||||
subscriber.GatewayCustomerId = null;
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.CustomerGetAsync(subscriber.GatewayCustomerId, Arg.Any<CustomerGetOptions>())
|
||||
.Returns((Customer)null);
|
||||
|
||||
await Assert.ThrowsAsync<BillingException>(() => sutProvider.Sut.GetPaymentMethodAsync(subscriber));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetPaymentMethodAsync_StripeCardPaymentMethod_ReturnsBillingSource(
|
||||
Provider subscriber,
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
{
|
||||
var customer = new Customer();
|
||||
var paymentMethod = CreateSamplePaymentMethod();
|
||||
subscriber.GatewayCustomerId = "test_customer_id";
|
||||
customer.InvoiceSettings = new CustomerInvoiceSettings
|
||||
{
|
||||
DefaultPaymentMethod = paymentMethod
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.CustomerGetAsync(subscriber.GatewayCustomerId, Arg.Any<CustomerGetOptions>())
|
||||
.Returns(customer);
|
||||
|
||||
var billingSource = await sutProvider.Sut.GetPaymentMethodAsync(subscriber);
|
||||
|
||||
Assert.NotNull(billingSource);
|
||||
Assert.Equal(paymentMethod.Card.Brand, billingSource.CardBrand);
|
||||
}
|
||||
|
||||
private static PaymentMethod CreateSamplePaymentMethod()
|
||||
{
|
||||
var paymentMethod = new PaymentMethod
|
||||
{
|
||||
Id = "pm_test123",
|
||||
Type = "card",
|
||||
Card = new PaymentMethodCard
|
||||
{
|
||||
Brand = "visa",
|
||||
Last4 = "4242",
|
||||
ExpMonth = 12,
|
||||
ExpYear = 2024
|
||||
}
|
||||
};
|
||||
return paymentMethod;
|
||||
}
|
||||
#endregion
|
||||
}
|
Loading…
Reference in New Issue
Block a user