using System.Globalization; using System.Net; using Bit.Commercial.Core.Billing; using Bit.Commercial.Core.Billing.Models; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using CsvHelper; using NSubstitute; using Stripe; using Xunit; using static Bit.Core.Test.Billing.Utilities; namespace Bit.Commercial.Core.Test.Billing; [SutProviderCustomize] public class ProviderBillingServiceTests { #region AssignSeatsToClientOrganization & ScaleSeats [Theory, BitAutoData] public Task AssignSeatsToClientOrganization_NullProvider_ArgumentNullException( Organization organization, int seats, SutProvider sutProvider) => Assert.ThrowsAsync(() => sutProvider.Sut.AssignSeatsToClientOrganization(null, organization, seats)); [Theory, BitAutoData] public Task AssignSeatsToClientOrganization_NullOrganization_ArgumentNullException( Provider provider, int seats, SutProvider sutProvider) => Assert.ThrowsAsync(() => sutProvider.Sut.AssignSeatsToClientOrganization(provider, null, seats)); [Theory, BitAutoData] public Task AssignSeatsToClientOrganization_NegativeSeats_BillingException( Provider provider, Organization organization, SutProvider sutProvider) => Assert.ThrowsAsync(() => sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, -5)); [Theory, BitAutoData] public async Task AssignSeatsToClientOrganization_CurrentSeatsMatchesNewSeats_NoOp( Provider provider, Organization organization, int seats, SutProvider sutProvider) { organization.PlanType = PlanType.TeamsMonthly; organization.Seats = seats; await sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats); await sutProvider.GetDependency().DidNotReceive().GetByProviderId(provider.Id); } [Theory, BitAutoData] public async Task AssignSeatsToClientOrganization_OrganizationPlanTypeDoesNotSupportConsolidatedBilling_ContactSupport( Provider provider, Organization organization, int seats, SutProvider sutProvider) { organization.PlanType = PlanType.FamiliesAnnually; await ThrowsBillingExceptionAsync(() => sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats)); } [Theory, BitAutoData] public async Task AssignSeatsToClientOrganization_ProviderPlanIsNotConfigured_ContactSupport( Provider provider, Organization organization, int seats, SutProvider sutProvider) { organization.PlanType = PlanType.TeamsMonthly; sutProvider.GetDependency().GetByProviderId(provider.Id).Returns(new List { new() { Id = Guid.NewGuid(), PlanType = PlanType.TeamsMonthly, ProviderId = provider.Id } }); await ThrowsBillingExceptionAsync(() => sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats)); } [Theory, BitAutoData] public async Task AssignSeatsToClientOrganization_BelowToBelow_Succeeds( Provider provider, Organization organization, SutProvider sutProvider) { organization.Seats = 10; organization.PlanType = PlanType.TeamsMonthly; // Scale up 10 seats const int seats = 20; var providerPlans = new List { 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().GetByProviderId(provider.Id).Returns(providerPlans); // 50 seats currently assigned with a seat minimum of 100 sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); sutProvider.GetDependency().GetManyDetailsByProviderAsync(provider.Id).Returns( [ new ProviderOrganizationOrganizationDetails { Plan = teamsMonthlyPlan.Name, Status = OrganizationStatusType.Managed, Seats = 25 }, new ProviderOrganizationOrganizationDetails { Plan = teamsMonthlyPlan.Name, Status = OrganizationStatusType.Managed, Seats = 25 } ]); await sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats); // 50 assigned seats + 10 seat scale up = 60 seats, well below the 100 minimum await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().AdjustSeats( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is( org => org.Id == organization.Id && org.Seats == seats)); await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is( pPlan => pPlan.AllocatedSeats == 60)); } [Theory, BitAutoData] public async Task AssignSeatsToClientOrganization_BelowToAbove_NotProviderAdmin_ContactSupport( Provider provider, Organization organization, SutProvider sutProvider) { organization.Seats = 10; organization.PlanType = PlanType.TeamsMonthly; // Scale up 10 seats const int seats = 20; var providerPlans = new List { 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 } }; sutProvider.GetDependency().GetByProviderId(provider.Id).Returns(providerPlans); // 95 seats currently assigned with a seat minimum of 100 sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); sutProvider.GetDependency().GetManyDetailsByProviderAsync(provider.Id).Returns( [ new ProviderOrganizationOrganizationDetails { Plan = teamsMonthlyPlan.Name, Status = OrganizationStatusType.Managed, Seats = 60 }, new ProviderOrganizationOrganizationDetails { Plan = teamsMonthlyPlan.Name, Status = OrganizationStatusType.Managed, Seats = 35 } ]); sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(false); await ThrowsBillingExceptionAsync(() => sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats)); } [Theory, BitAutoData] public async Task AssignSeatsToClientOrganization_BelowToAbove_Succeeds( Provider provider, Organization organization, SutProvider sutProvider) { organization.Seats = 10; organization.PlanType = PlanType.TeamsMonthly; // Scale up 10 seats const int seats = 20; var providerPlans = new List { 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().GetByProviderId(provider.Id).Returns(providerPlans); // 95 seats currently assigned with a seat minimum of 100 sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); sutProvider.GetDependency().GetManyDetailsByProviderAsync(provider.Id).Returns( [ new ProviderOrganizationOrganizationDetails { Plan = teamsMonthlyPlan.Name, Status = OrganizationStatusType.Managed, Seats = 60 }, new ProviderOrganizationOrganizationDetails { Plan = teamsMonthlyPlan.Name, Status = OrganizationStatusType.Managed, Seats = 35 } ]); sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); await sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats); // 95 current + 10 seat scale = 105 seats, 5 above the minimum await sutProvider.GetDependency().Received(1).AdjustSeats( provider, StaticStore.GetPlan(providerPlan.PlanType), providerPlan.SeatMinimum!.Value, 105); await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is( org => org.Id == organization.Id && org.Seats == seats)); // 105 total seats - 100 minimum = 5 purchased seats await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is( pPlan => pPlan.Id == providerPlan.Id && pPlan.PurchasedSeats == 5 && pPlan.AllocatedSeats == 105)); } [Theory, BitAutoData] public async Task AssignSeatsToClientOrganization_AboveToAbove_Succeeds( Provider provider, Organization organization, SutProvider sutProvider) { provider.Type = ProviderType.Msp; organization.Seats = 10; organization.PlanType = PlanType.TeamsMonthly; // Scale up 10 seats const int seats = 20; var providerPlans = new List { 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().GetByProviderId(provider.Id).Returns(providerPlans); // 110 seats currently assigned with a seat minimum of 100 sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); sutProvider.GetDependency().GetManyDetailsByProviderAsync(provider.Id).Returns( [ new ProviderOrganizationOrganizationDetails { Plan = teamsMonthlyPlan.Name, Status = OrganizationStatusType.Managed, Seats = 60 }, new ProviderOrganizationOrganizationDetails { Plan = teamsMonthlyPlan.Name, Status = OrganizationStatusType.Managed, Seats = 50 } ]); await sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats); // 110 current + 10 seat scale up = 120 seats await sutProvider.GetDependency().Received(1).AdjustSeats( provider, StaticStore.GetPlan(providerPlan.PlanType), 110, 120); await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is( org => org.Id == organization.Id && org.Seats == seats)); // 120 total seats - 100 seat minimum = 20 purchased seats await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is( pPlan => pPlan.Id == providerPlan.Id && pPlan.PurchasedSeats == 20 && pPlan.AllocatedSeats == 120)); } [Theory, BitAutoData] public async Task AssignSeatsToClientOrganization_AboveToBelow_Succeeds( Provider provider, Organization organization, SutProvider sutProvider) { organization.Seats = 50; organization.PlanType = PlanType.TeamsMonthly; // Scale down 30 seats const int seats = 20; var providerPlans = new List { 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().GetByProviderId(provider.Id).Returns(providerPlans); // 110 seats currently assigned with a seat minimum of 100 sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); sutProvider.GetDependency().GetManyDetailsByProviderAsync(provider.Id).Returns( [ new ProviderOrganizationOrganizationDetails { Plan = teamsMonthlyPlan.Name, Status = OrganizationStatusType.Managed, Seats = 60 }, new ProviderOrganizationOrganizationDetails { Plan = teamsMonthlyPlan.Name, Status = OrganizationStatusType.Managed, Seats = 50 } ]); await sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats); // 110 seats - 30 scale down seats = 80 seats, below the 100 seat minimum. await sutProvider.GetDependency().Received(1).AdjustSeats( provider, StaticStore.GetPlan(providerPlan.PlanType), 110, providerPlan.SeatMinimum!.Value); await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is( org => org.Id == organization.Id && org.Seats == seats)); // Being below the seat minimum means no purchased seats. await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is( pPlan => pPlan.Id == providerPlan.Id && pPlan.PurchasedSeats == 0 && pPlan.AllocatedSeats == 80)); } #endregion #region CreateCustomerForClientOrganization [Theory, BitAutoData] public async Task CreateCustomerForClientOrganization_ProviderNull_ThrowsArgumentNullException( Organization organization, SutProvider sutProvider) => await Assert.ThrowsAsync(() => sutProvider.Sut.CreateCustomerForClientOrganization(null, organization)); [Theory, BitAutoData] public async Task CreateCustomerForClientOrganization_OrganizationNull_ThrowsArgumentNullException( Provider provider, SutProvider sutProvider) => await Assert.ThrowsAsync(() => sutProvider.Sut.CreateCustomerForClientOrganization(provider, null)); [Theory, BitAutoData] public async Task CreateCustomerForClientOrganization_HasGatewayCustomerId_NoOp( Provider provider, Organization organization, SutProvider sutProvider) { organization.GatewayCustomerId = "customer_id"; await sutProvider.Sut.CreateCustomerForClientOrganization(provider, organization); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() .GetCustomerOrThrow(Arg.Any(), Arg.Any()); } [Theory, BitAutoData] public async Task CreateCustomer_ForClientOrg_Succeeds( Provider provider, Organization organization, SutProvider 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 { Data = [ new TaxId { Type = "TYPE", Value = "VALUE" } ] } }; sutProvider.GetDependency().GetCustomerOrThrow(provider, Arg.Is( options => options.Expand.FirstOrDefault() == "tax_ids")) .Returns(providerCustomer); sutProvider.GetDependency().BaseServiceUri .Returns(new Bit.Core.Settings.GlobalSettings.BaseServiceUriSettings(new Bit.Core.Settings.GlobalSettings()) { CloudRegion = "US" }); sutProvider.GetDependency().CustomerCreateAsync(Arg.Is( 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.CreateCustomerForClientOrganization(provider, organization); await sutProvider.GetDependency().Received(1).CustomerCreateAsync(Arg.Is( 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().Received(1).ReplaceAsync(Arg.Is( org => org.GatewayCustomerId == "customer_id")); } #endregion #region GenerateClientInvoiceReport [Theory, BitAutoData] public async Task GenerateClientInvoiceReport_NullInvoiceId_ThrowsArgumentNullException( SutProvider sutProvider) => await Assert.ThrowsAsync(() => sutProvider.Sut.GenerateClientInvoiceReport(null)); [Theory, BitAutoData] public async Task GenerateClientInvoiceReport_NoInvoiceItems_ReturnsNull( string invoiceId, SutProvider sutProvider) { sutProvider.GetDependency().GetByInvoiceId(invoiceId).Returns([]); var reportContent = await sutProvider.Sut.GenerateClientInvoiceReport(invoiceId); Assert.Null(reportContent); } [Theory, BitAutoData] public async Task GenerateClientInvoiceReport_Succeeds( string invoiceId, SutProvider sutProvider) { var clientId = Guid.NewGuid(); var invoiceItems = new List { new () { ClientId = clientId, ClientName = "Client 1", AssignedSeats = 50, UsedSeats = 30, PlanName = "Teams (Monthly)", Total = 500 } }; sutProvider.GetDependency().GetByInvoiceId(invoiceId).Returns(invoiceItems); var reportContent = await sutProvider.Sut.GenerateClientInvoiceReport(invoiceId); using var memoryStream = new MemoryStream(reportContent); using var streamReader = new StreamReader(memoryStream); using var csvReader = new CsvReader(streamReader, CultureInfo.InvariantCulture); var records = csvReader.GetRecords().ToList(); Assert.Single(records); var record = records.First(); Assert.Equal(clientId.ToString(), record.Id); Assert.Equal("Client 1", record.Client); Assert.Equal(50, record.Assigned); Assert.Equal(30, record.Used); Assert.Equal(20, record.Remaining); Assert.Equal("Teams (Monthly)", record.Plan); Assert.Equal("$500.00", record.Total); } #endregion #region GetAssignedSeatTotalForPlanOrThrow [Theory, BitAutoData] public async Task GetAssignedSeatTotalForPlanOrThrow_NullProvider_ContactSupport( Guid providerId, SutProvider sutProvider) => await ThrowsBillingExceptionAsync(() => sutProvider.Sut.GetAssignedSeatTotalForPlanOrThrow(providerId, PlanType.TeamsMonthly)); [Theory, BitAutoData] public async Task GetAssignedSeatTotalForPlanOrThrow_ResellerProvider_ContactSupport( Guid providerId, Provider provider, SutProvider sutProvider) { provider.Type = ProviderType.Reseller; sutProvider.GetDependency().GetByIdAsync(providerId).Returns(provider); await ThrowsBillingExceptionAsync( () => sutProvider.Sut.GetAssignedSeatTotalForPlanOrThrow(providerId, PlanType.TeamsMonthly)); } [Theory, BitAutoData] public async Task GetAssignedSeatTotalForPlanOrThrow_Succeeds( Guid providerId, Provider provider, SutProvider sutProvider) { provider.Type = ProviderType.Msp; sutProvider.GetDependency().GetByIdAsync(providerId).Returns(provider); var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); var enterpriseMonthlyPlan = StaticStore.GetPlan(PlanType.EnterpriseMonthly); var providerOrganizationOrganizationDetailList = new List { new() { Plan = teamsMonthlyPlan.Name, Status = OrganizationStatusType.Managed, Seats = 10 }, new() { Plan = teamsMonthlyPlan.Name, Status = OrganizationStatusType.Managed, Seats = 10 }, new() { // Ignored because of status. Plan = teamsMonthlyPlan.Name, Status = OrganizationStatusType.Created, Seats = 100 }, new() { // Ignored because of plan. Plan = enterpriseMonthlyPlan.Name, Status = OrganizationStatusType.Managed, Seats = 30 } }; sutProvider.GetDependency() .GetManyDetailsByProviderAsync(providerId) .Returns(providerOrganizationOrganizationDetailList); var assignedSeatTotal = await sutProvider.Sut.GetAssignedSeatTotalForPlanOrThrow(providerId, PlanType.TeamsMonthly); Assert.Equal(20, assignedSeatTotal); } #endregion #region SetupCustomer [Theory, BitAutoData] public async Task SetupCustomer_NullProvider_ThrowsArgumentNullException( SutProvider sutProvider, TaxInfo taxInfo) => await Assert.ThrowsAsync(() => sutProvider.Sut.SetupCustomer(null, taxInfo)); [Theory, BitAutoData] public async Task SetupCustomer_NullTaxInfo_ThrowsArgumentNullException( SutProvider sutProvider, Provider provider) => await Assert.ThrowsAsync(() => sutProvider.Sut.SetupCustomer(provider, null)); [Theory, BitAutoData] public async Task SetupCustomer_MissingCountry_ContactSupport( SutProvider sutProvider, Provider provider, TaxInfo taxInfo) { taxInfo.BillingAddressCountry = null; await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupCustomer(provider, taxInfo)); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .CustomerGetAsync(Arg.Any(), Arg.Any()); } [Theory, BitAutoData] public async Task SetupCustomer_MissingPostalCode_ContactSupport( SutProvider sutProvider, Provider provider, TaxInfo taxInfo) { taxInfo.BillingAddressCountry = null; await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupCustomer(provider, taxInfo)); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .CustomerGetAsync(Arg.Any(), Arg.Any()); } [Theory, BitAutoData] public async Task SetupCustomer_Success( SutProvider sutProvider, Provider provider, TaxInfo taxInfo) { provider.Name = "MSP"; taxInfo.BillingAddressCountry = "AD"; var stripeAdapter = sutProvider.GetDependency(); var expected = new Customer { Id = "customer_id", Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } }; stripeAdapter.CustomerCreateAsync(Arg.Is(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.Description == WebUtility.HtmlDecode(provider.BusinessName) && o.Email == provider.BillingEmail && 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(expected); var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo); Assert.Equivalent(expected, actual); } #endregion #region SetupSubscription [Theory, BitAutoData] public async Task SetupSubscription_NullProvider_ThrowsArgumentNullException( SutProvider sutProvider) => await Assert.ThrowsAsync(() => sutProvider.Sut.SetupSubscription(null)); [Theory, BitAutoData] public async Task SetupSubscription_NoProviderPlans_ContactSupport( SutProvider sutProvider, Provider provider) { provider.GatewaySubscriptionId = null; sutProvider.GetDependency().GetCustomerOrThrow(provider).Returns(new Customer { Id = "customer_id", Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } }); sutProvider.GetDependency().GetByProviderId(provider.Id) .Returns(new List()); await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupSubscription(provider)); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .SubscriptionCreateAsync(Arg.Any()); } [Theory, BitAutoData] public async Task SetupSubscription_NoProviderTeamsPlan_ContactSupport( SutProvider sutProvider, Provider provider) { provider.GatewaySubscriptionId = null; sutProvider.GetDependency().GetCustomerOrThrow(provider).Returns(new Customer { Id = "customer_id", Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } }); var providerPlans = new List { new() { PlanType = PlanType.EnterpriseMonthly } }; sutProvider.GetDependency().GetByProviderId(provider.Id) .Returns(providerPlans); await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupSubscription(provider)); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .SubscriptionCreateAsync(Arg.Any()); } [Theory, BitAutoData] public async Task SetupSubscription_NoProviderEnterprisePlan_ContactSupport( SutProvider sutProvider, Provider provider) { provider.GatewaySubscriptionId = null; sutProvider.GetDependency().GetCustomerOrThrow(provider).Returns(new Customer { Id = "customer_id", Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } }); var providerPlans = new List { new() { PlanType = PlanType.TeamsMonthly } }; sutProvider.GetDependency().GetByProviderId(provider.Id) .Returns(providerPlans); await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupSubscription(provider)); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .SubscriptionCreateAsync(Arg.Any()); } [Theory, BitAutoData] public async Task SetupSubscription_SubscriptionIncomplete_ThrowsBillingException( SutProvider sutProvider, Provider provider) { provider.GatewaySubscriptionId = null; sutProvider.GetDependency().GetCustomerOrThrow(provider).Returns(new Customer { Id = "customer_id", Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } }); var providerPlans = new List { new() { Id = Guid.NewGuid(), ProviderId = provider.Id, PlanType = PlanType.TeamsMonthly, SeatMinimum = 100, PurchasedSeats = 0, AllocatedSeats = 0 }, new() { Id = Guid.NewGuid(), ProviderId = provider.Id, PlanType = PlanType.EnterpriseMonthly, SeatMinimum = 100, PurchasedSeats = 0, AllocatedSeats = 0 } }; sutProvider.GetDependency().GetByProviderId(provider.Id) .Returns(providerPlans); sutProvider.GetDependency().SubscriptionCreateAsync(Arg.Any()) .Returns( new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Incomplete }); await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupSubscription(provider)); } [Theory, BitAutoData] public async Task SetupSubscription_Succeeds( SutProvider sutProvider, Provider provider) { provider.GatewaySubscriptionId = null; sutProvider.GetDependency().GetCustomerOrThrow(provider).Returns(new Customer { Id = "customer_id", Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } }); var providerPlans = new List { new() { Id = Guid.NewGuid(), ProviderId = provider.Id, PlanType = PlanType.TeamsMonthly, SeatMinimum = 100, PurchasedSeats = 0, AllocatedSeats = 0 }, new() { Id = Guid.NewGuid(), ProviderId = provider.Id, PlanType = PlanType.EnterpriseMonthly, SeatMinimum = 100, PurchasedSeats = 0, AllocatedSeats = 0 } }; sutProvider.GetDependency().GetByProviderId(provider.Id) .Returns(providerPlans); var teamsPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); var enterprisePlan = StaticStore.GetPlan(PlanType.EnterpriseMonthly); var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active }; sutProvider.GetDependency().SubscriptionCreateAsync(Arg.Is( sub => sub.AutomaticTax.Enabled == true && sub.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice && sub.Customer == "customer_id" && sub.DaysUntilDue == 30 && sub.Items.Count == 2 && sub.Items.ElementAt(0).Price == teamsPlan.PasswordManager.StripeProviderPortalSeatPlanId && sub.Items.ElementAt(0).Quantity == 100 && sub.Items.ElementAt(1).Price == enterprisePlan.PasswordManager.StripeProviderPortalSeatPlanId && sub.Items.ElementAt(1).Quantity == 100 && sub.Metadata["providerId"] == provider.Id.ToString() && sub.OffSession == true && sub.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations)).Returns(expected); var actual = await sutProvider.Sut.SetupSubscription(provider); Assert.Equivalent(expected, actual); } #endregion #region UpdateSeatMinimums [Theory, BitAutoData] public async Task UpdateSeatMinimums_NullProvider_ThrowsArgumentNullException( SutProvider sutProvider) => await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSeatMinimums(null, 0, 0)); [Theory, BitAutoData] public async Task UpdateSeatMinimums_NegativeSeatMinimum_ThrowsBadRequestException( Provider provider, SutProvider sutProvider) => await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSeatMinimums(provider, -10, 100)); [Theory, BitAutoData] public async Task UpdateSeatMinimums_NoPurchasedSeats_SyncsStripeWithNewSeatMinimum( Provider provider, SutProvider sutProvider) { var stripeAdapter = sutProvider.GetDependency(); var providerPlanRepository = sutProvider.GetDependency(); const string enterpriseLineItemId = "enterprise_line_item_id"; const string teamsLineItemId = "teams_line_item_id"; var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId; var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId; var subscription = new Subscription { Items = new StripeList { Data = [ new SubscriptionItem { Id = enterpriseLineItemId, Price = new Price { Id = enterprisePriceId } }, new SubscriptionItem { Id = teamsLineItemId, Price = new Price { Id = teamsPriceId } } ] } }; stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId).Returns(subscription); var providerPlans = new List { new() { PlanType = PlanType.EnterpriseMonthly, SeatMinimum = 50, PurchasedSeats = 0 }, new() { PlanType = PlanType.TeamsMonthly, SeatMinimum = 30, PurchasedSeats = 0 } }; providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); await sutProvider.Sut.UpdateSeatMinimums(provider, 70, 50); await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is( providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 70)); await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is( providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly && providerPlan.SeatMinimum == 50)); await stripeAdapter.Received(1).SubscriptionUpdateAsync(provider.GatewaySubscriptionId, Arg.Is( options => options.Items.Count == 2 && options.Items.ElementAt(0).Id == enterpriseLineItemId && options.Items.ElementAt(0).Quantity == 70 && options.Items.ElementAt(1).Id == teamsLineItemId && options.Items.ElementAt(1).Quantity == 50)); } [Theory, BitAutoData] public async Task UpdateSeatMinimums_PurchasedSeats_NewMinimumLessThanTotal_UpdatesPurchasedSeats( Provider provider, SutProvider sutProvider) { var stripeAdapter = sutProvider.GetDependency(); var providerPlanRepository = sutProvider.GetDependency(); const string enterpriseLineItemId = "enterprise_line_item_id"; const string teamsLineItemId = "teams_line_item_id"; var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId; var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId; var subscription = new Subscription { Items = new StripeList { Data = [ new SubscriptionItem { Id = enterpriseLineItemId, Price = new Price { Id = enterprisePriceId } }, new SubscriptionItem { Id = teamsLineItemId, Price = new Price { Id = teamsPriceId } } ] } }; stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId).Returns(subscription); var providerPlans = new List { new() { PlanType = PlanType.EnterpriseMonthly, SeatMinimum = 50, PurchasedSeats = 20 }, new() { PlanType = PlanType.TeamsMonthly, SeatMinimum = 50, PurchasedSeats = 20 } }; providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); await sutProvider.Sut.UpdateSeatMinimums(provider, 60, 60); await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is( providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 60 && providerPlan.PurchasedSeats == 10)); await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is( providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly && providerPlan.SeatMinimum == 60 && providerPlan.PurchasedSeats == 10)); await stripeAdapter.DidNotReceiveWithAnyArgs() .SubscriptionUpdateAsync(Arg.Any(), Arg.Any()); } [Theory, BitAutoData] public async Task UpdateSeatMinimums_PurchasedSeats_NewMinimumGreaterThanTotal_ClearsPurchasedSeats_SyncsStripeWithNewSeatMinimum( Provider provider, SutProvider sutProvider) { var stripeAdapter = sutProvider.GetDependency(); var providerPlanRepository = sutProvider.GetDependency(); const string enterpriseLineItemId = "enterprise_line_item_id"; const string teamsLineItemId = "teams_line_item_id"; var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId; var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId; var subscription = new Subscription { Items = new StripeList { Data = [ new SubscriptionItem { Id = enterpriseLineItemId, Price = new Price { Id = enterprisePriceId } }, new SubscriptionItem { Id = teamsLineItemId, Price = new Price { Id = teamsPriceId } } ] } }; stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId).Returns(subscription); var providerPlans = new List { new() { PlanType = PlanType.EnterpriseMonthly, SeatMinimum = 50, PurchasedSeats = 20 }, new() { PlanType = PlanType.TeamsMonthly, SeatMinimum = 50, PurchasedSeats = 20 } }; providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); await sutProvider.Sut.UpdateSeatMinimums(provider, 80, 80); await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is( providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 80 && providerPlan.PurchasedSeats == 0)); await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is( providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly && providerPlan.SeatMinimum == 80 && providerPlan.PurchasedSeats == 0)); await stripeAdapter.Received(1).SubscriptionUpdateAsync(provider.GatewaySubscriptionId, Arg.Is( options => options.Items.Count == 2 && options.Items.ElementAt(0).Id == enterpriseLineItemId && options.Items.ElementAt(0).Quantity == 80 && options.Items.ElementAt(1).Id == teamsLineItemId && options.Items.ElementAt(1).Quantity == 80)); } [Theory, BitAutoData] public async Task UpdateSeatMinimums_SinglePlanTypeUpdate_Succeeds( Provider provider, SutProvider sutProvider) { var stripeAdapter = sutProvider.GetDependency(); var providerPlanRepository = sutProvider.GetDependency(); const string enterpriseLineItemId = "enterprise_line_item_id"; const string teamsLineItemId = "teams_line_item_id"; var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId; var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId; var subscription = new Subscription { Items = new StripeList { Data = [ new SubscriptionItem { Id = enterpriseLineItemId, Price = new Price { Id = enterprisePriceId } }, new SubscriptionItem { Id = teamsLineItemId, Price = new Price { Id = teamsPriceId } } ] } }; stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId).Returns(subscription); var providerPlans = new List { new() { PlanType = PlanType.EnterpriseMonthly, SeatMinimum = 50, PurchasedSeats = 0 }, new() { PlanType = PlanType.TeamsMonthly, SeatMinimum = 30, PurchasedSeats = 0 } }; providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); await sutProvider.Sut.UpdateSeatMinimums(provider, 70, 30); await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is( providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 70)); await providerPlanRepository.DidNotReceive().ReplaceAsync(Arg.Is( providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly)); await stripeAdapter.Received(1).SubscriptionUpdateAsync(provider.GatewaySubscriptionId, Arg.Is( options => options.Items.Count == 1 && options.Items.ElementAt(0).Id == enterpriseLineItemId && options.Items.ElementAt(0).Quantity == 70)); } #endregion }