1
0
mirror of https://github.com/bitwarden/server.git synced 2024-11-24 12:35:25 +01:00

Start subscription for provider during setup process. (#3957)

This commit is contained in:
Alex Morask 2024-04-10 14:10:53 -04:00 committed by GitHub
parent 2c36784cda
commit 3cdfbdb22d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 749 additions and 17 deletions

View File

@ -724,7 +724,7 @@ public class OrganizationsController : Controller
[HttpPut("{id}/tax")] [HttpPut("{id}/tax")]
[SelfHosted(NotSelfHostedOnly = true)] [SelfHosted(NotSelfHostedOnly = true)]
public async Task PutTaxInfo(string id, [FromBody] OrganizationTaxInfoUpdateRequestModel model) public async Task PutTaxInfo(string id, [FromBody] ExpandedTaxInfoUpdateRequestModel model)
{ {
var orgIdGuid = new Guid(id); var orgIdGuid = new Guid(id);
if (!await _currentContext.OrganizationOwner(orgIdGuid)) if (!await _currentContext.OrganizationOwner(orgIdGuid))

View File

@ -1,9 +1,12 @@
using Bit.Api.AdminConsole.Models.Request.Providers; using Bit.Api.AdminConsole.Models.Request.Providers;
using Bit.Api.AdminConsole.Models.Response.Providers; using Bit.Api.AdminConsole.Models.Response.Providers;
using Bit.Core;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Commands;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@ -20,15 +23,23 @@ public class ProvidersController : Controller
private readonly IProviderService _providerService; private readonly IProviderService _providerService;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly GlobalSettings _globalSettings; private readonly GlobalSettings _globalSettings;
private readonly IFeatureService _featureService;
private readonly IStartSubscriptionCommand _startSubscriptionCommand;
private readonly ILogger<ProvidersController> _logger;
public ProvidersController(IUserService userService, IProviderRepository providerRepository, public ProvidersController(IUserService userService, IProviderRepository providerRepository,
IProviderService providerService, ICurrentContext currentContext, GlobalSettings globalSettings) IProviderService providerService, ICurrentContext currentContext, GlobalSettings globalSettings,
IFeatureService featureService, IStartSubscriptionCommand startSubscriptionCommand,
ILogger<ProvidersController> logger)
{ {
_userService = userService; _userService = userService;
_providerRepository = providerRepository; _providerRepository = providerRepository;
_providerService = providerService; _providerService = providerService;
_currentContext = currentContext; _currentContext = currentContext;
_globalSettings = globalSettings; _globalSettings = globalSettings;
_featureService = featureService;
_startSubscriptionCommand = startSubscriptionCommand;
_logger = logger;
} }
[HttpGet("{id:guid}")] [HttpGet("{id:guid}")]
@ -86,6 +97,30 @@ public class ProvidersController : Controller
var response = var response =
await _providerService.CompleteSetupAsync(model.ToProvider(provider), userId, model.Token, model.Key); await _providerService.CompleteSetupAsync(model.ToProvider(provider), userId, model.Token, model.Key);
if (_featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling))
{
var taxInfo = new TaxInfo
{
BillingAddressCountry = model.TaxInfo.Country,
BillingAddressPostalCode = model.TaxInfo.PostalCode,
TaxIdNumber = model.TaxInfo.TaxId,
BillingAddressLine1 = model.TaxInfo.Line1,
BillingAddressLine2 = model.TaxInfo.Line2,
BillingAddressCity = model.TaxInfo.City,
BillingAddressState = model.TaxInfo.State
};
try
{
await _startSubscriptionCommand.StartSubscription(provider, taxInfo);
}
catch
{
// We don't want to trap the user on the setup page, so we'll let this go through but the provider will be in an un-billable state.
_logger.LogError("Failed to create subscription for provider with ID {ID} during setup", provider.Id);
}
}
return new ProviderResponseModel(response); return new ProviderResponseModel(response);
} }
} }

View File

@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Bit.Api.Models.Request;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Utilities; using Bit.Core.Utilities;
@ -22,6 +23,7 @@ public class ProviderSetupRequestModel
public string Token { get; set; } public string Token { get; set; }
[Required] [Required]
public string Key { get; set; } public string Key { get; set; }
public ExpandedTaxInfoUpdateRequestModel TaxInfo { get; set; }
public virtual Provider ToProvider(Provider provider) public virtual Provider ToProvider(Provider provider)
{ {

View File

@ -1,8 +1,8 @@
using Bit.Api.Models.Request.Accounts; using Bit.Api.Models.Request.Accounts;
namespace Bit.Api.Models.Request.Organizations; namespace Bit.Api.Models.Request;
public class OrganizationTaxInfoUpdateRequestModel : TaxInfoUpdateRequestModel public class ExpandedTaxInfoUpdateRequestModel : TaxInfoUpdateRequestModel
{ {
public string TaxId { get; set; } public string TaxId { get; set; }
public string Line1 { get; set; } public string Line1 { get; set; }

View File

@ -1,10 +1,9 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Bit.Api.Models.Request.Organizations;
using Bit.Core.Enums; using Bit.Core.Enums;
namespace Bit.Api.Models.Request; namespace Bit.Api.Models.Request;
public class PaymentRequestModel : OrganizationTaxInfoUpdateRequestModel public class PaymentRequestModel : ExpandedTaxInfoUpdateRequestModel
{ {
[Required] [Required]
public PaymentMethodType? PaymentMethodType { get; set; } public PaymentMethodType? PaymentMethodType { get; set; }

View File

@ -255,7 +255,7 @@ public class StripeController : Controller
customerGetOptions.AddExpand("tax"); customerGetOptions.AddExpand("tax");
var customer = await _stripeFacade.GetCustomer(subscription.CustomerId, customerGetOptions); var customer = await _stripeFacade.GetCustomer(subscription.CustomerId, customerGetOptions);
if (!subscription.AutomaticTax.Enabled && if (!subscription.AutomaticTax.Enabled &&
customer.Tax?.AutomaticTax == StripeCustomerAutomaticTaxStatus.Supported) customer.Tax?.AutomaticTax == StripeConstants.AutomaticTaxStatus.Supported)
{ {
subscription = await _stripeFacade.UpdateSubscription(subscription.Id, subscription = await _stripeFacade.UpdateSubscription(subscription.Id,
new SubscriptionUpdateOptions new SubscriptionUpdateOptions

View File

@ -4,4 +4,5 @@ public enum ProviderStatusType : byte
{ {
Pending = 0, Pending = 0,
Created = 1, Created = 1,
Billable = 2
} }

View File

@ -0,0 +1,11 @@
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Models.Business;
namespace Bit.Core.Billing.Commands;
public interface IStartSubscriptionCommand
{
Task StartSubscription(
Provider provider,
TaxInfo taxInfo);
}

View File

@ -0,0 +1,209 @@
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);
if (taxInfo.BillingAddressCountry == "US" && customer.Tax is not { AutomaticTax: StripeConstants.AutomaticTaxStatus.Supported })
{
logger.LogError("Cannot start Provider subscription - Provider's ({ProviderID}) Stripe customer ({CustomerID}) is in the US and does not support automatic tax", provider.Id, customer.Id);
throw ContactSupport();
}
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
if (providerPlans == null || providerPlans.Count == 0)
{
logger.LogError("Cannot start Provider subscription - Provider ({ID}) has no configured plans", provider.Id);
throw ContactSupport();
}
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>();
var teamsProviderPlan =
providerPlans.SingleOrDefault(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly);
if (teamsProviderPlan == null)
{
logger.LogError("Cannot start Provider subscription - Provider ({ID}) has no configured Teams Monthly plan", provider.Id);
throw ContactSupport();
}
var teamsPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Price = teamsPlan.PasswordManager.StripeSeatPlanId,
Quantity = teamsProviderPlan.SeatMinimum
});
var enterpriseProviderPlan =
providerPlans.SingleOrDefault(providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly);
if (enterpriseProviderPlan == null)
{
logger.LogError("Cannot start Provider subscription - Provider ({ID}) has no configured Enterprise Monthly plan", provider.Id);
throw ContactSupport();
}
var enterprisePlan = StaticStore.GetPlan(PlanType.EnterpriseMonthly);
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Price = enterprisePlan.PasswordManager.StripeSeatPlanId,
Quantity = enterpriseProviderPlan.SeatMinimum
});
var subscriptionCreateOptions = new SubscriptionCreateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = true
},
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
Customer = customer.Id,
DaysUntilDue = 30,
Items = subscriptionItemOptionsList,
Metadata = new Dictionary<string, string>
{
{ "providerId", provider.Id.ToString() }
},
OffSession = true,
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations
};
var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
provider.GatewaySubscriptionId = subscription.Id;
if (subscription.Status == StripeConstants.SubscriptionStatus.Incomplete)
{
await providerRepository.ReplaceAsync(provider);
logger.LogError("Started incomplete Provider ({ProviderID}) subscription ({SubscriptionID})", provider.Id, subscription.Id);
throw ContactSupport();
}
provider.Status = ProviderStatusType.Billable;
await providerRepository.ReplaceAsync(provider);
}
// ReSharper disable once SuggestBaseTypeForParameter
private async Task<Customer> GetOrCreateCustomerAsync(
Provider provider,
TaxInfo taxInfo)
{
if (!string.IsNullOrEmpty(provider.GatewayCustomerId))
{
var existingCustomer = await stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId, new CustomerGetOptions
{
Expand = ["tax"]
});
if (existingCustomer != null)
{
return existingCustomer;
}
logger.LogError("Cannot start Provider subscription - Provider's ({ProviderID}) {CustomerIDFieldName} did not relate to a Stripe customer", provider.Id, nameof(provider.GatewayCustomerId));
throw ContactSupport();
}
var providerDisplayName = provider.DisplayName();
var customerCreateOptions = new CustomerCreateOptions
{
Address = new AddressOptions
{
Country = taxInfo.BillingAddressCountry,
PostalCode = taxInfo.BillingAddressPostalCode,
Line1 = taxInfo.BillingAddressLine1,
Line2 = taxInfo.BillingAddressLine2,
City = taxInfo.BillingAddressCity,
State = taxInfo.BillingAddressState
},
Coupon = "msp-discount-35",
Description = provider.DisplayBusinessName(),
Email = provider.BillingEmail,
Expand = ["tax"],
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
CustomFields =
[
new CustomerInvoiceSettingsCustomFieldOptions
{
Name = provider.SubscriberType(),
Value = providerDisplayName.Length <= 30
? providerDisplayName
: providerDisplayName[..30]
}
]
},
Metadata = new Dictionary<string, string>
{
{ "region", globalSettings.BaseServiceUri.CloudRegion }
},
TaxIdData = taxInfo.HasTaxId ?
[
new CustomerTaxIdDataOptions { Type = taxInfo.TaxIdType, Value = taxInfo.TaxIdNumber }
]
: null
};
var createdCustomer = await stripeAdapter.CustomerCreateAsync(customerCreateOptions);
provider.GatewayCustomerId = createdCustomer.Id;
await providerRepository.ReplaceAsync(provider);
return createdCustomer;
}
}

View File

@ -0,0 +1,37 @@
namespace Bit.Core.Billing.Constants;
public static class StripeConstants
{
public static class AutomaticTaxStatus
{
public const string Failed = "failed";
public const string NotCollecting = "not_collecting";
public const string Supported = "supported";
public const string UnrecognizedLocation = "unrecognized_location";
}
public static class CollectionMethod
{
public const string ChargeAutomatically = "charge_automatically";
public const string SendInvoice = "send_invoice";
}
public static class ProrationBehavior
{
public const string AlwaysInvoice = "always_invoice";
public const string CreateProrations = "create_prorations";
public const string None = "none";
}
public static class SubscriptionStatus
{
public const string Trialing = "trialing";
public const string Active = "active";
public const string Incomplete = "incomplete";
public const string IncompleteExpired = "incomplete_expired";
public const string PastDue = "past_due";
public const string Canceled = "canceled";
public const string Unpaid = "unpaid";
public const string Paused = "paused";
}
}

View File

@ -1,9 +0,0 @@
namespace Bit.Core.Billing.Constants;
public static class StripeCustomerAutomaticTaxStatus
{
public const string Failed = "failed";
public const string NotCollecting = "not_collecting";
public const string Supported = "supported";
public const string UnrecognizedLocation = "unrecognized_location";
}

View File

@ -19,5 +19,6 @@ public static class ServiceCollectionExtensions
services.AddTransient<IAssignSeatsToClientOrganizationCommand, AssignSeatsToClientOrganizationCommand>(); services.AddTransient<IAssignSeatsToClientOrganizationCommand, AssignSeatsToClientOrganizationCommand>();
services.AddTransient<ICancelSubscriptionCommand, CancelSubscriptionCommand>(); services.AddTransient<ICancelSubscriptionCommand, CancelSubscriptionCommand>();
services.AddTransient<IRemovePaymentMethodCommand, RemovePaymentMethodCommand>(); services.AddTransient<IRemovePaymentMethodCommand, RemovePaymentMethodCommand>();
services.AddTransient<IStartSubscriptionCommand, StartSubscriptionCommand>();
} }
} }

View File

@ -1923,7 +1923,7 @@ public class StripePaymentService : IPaymentService
/// <param name="customer"></param> /// <param name="customer"></param>
/// <returns></returns> /// <returns></returns>
private static bool CustomerHasTaxLocationVerified(Customer customer) => private static bool CustomerHasTaxLocationVerified(Customer customer) =>
customer?.Tax?.AutomaticTax == StripeCustomerAutomaticTaxStatus.Supported; customer?.Tax?.AutomaticTax == StripeConstants.AutomaticTaxStatus.Supported;
// We are taking only first 30 characters of the SubscriberName because stripe provide // We are taking only first 30 characters of the SubscriberName because stripe provide
// for 30 characters for custom_fields,see the link: https://stripe.com/docs/api/invoices/create // for 30 characters for custom_fields,see the link: https://stripe.com/docs/api/invoices/create

View File

@ -0,0 +1,446 @@
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_CustomerDoesNotSupportAutomaticTax_ThrowsBillingException(
SutProvider<StartSubscriptionCommand> sutProvider,
Provider provider,
TaxInfo taxInfo)
{
provider.GatewayCustomerId = _customerId;
provider.GatewaySubscriptionId = null;
taxInfo.BillingAddressCountry = "US";
SetCustomerRetrieval(sutProvider, new Customer
{
Id = _customerId,
Tax = new CustomerTax
{
AutomaticTax = StripeConstants.AutomaticTaxStatus.NotCollecting
}
});
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);
}