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.Models; 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.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 ThrowsContactSupportAsync(() => 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 ThrowsContactSupportAsync(() => 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 ThrowsContactSupportAsync(() => 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 CreateCustomer [Theory, BitAutoData] public async Task CreateCustomer_NullProvider_ThrowsArgumentNullException( SutProvider sutProvider, TaxInfo taxInfo) => await Assert.ThrowsAsync(() => sutProvider.Sut.CreateCustomer(null, taxInfo)); [Theory, BitAutoData] public async Task CreateCustomer_NullTaxInfo_ThrowsArgumentNullException( SutProvider sutProvider, Provider provider) => await Assert.ThrowsAsync(() => sutProvider.Sut.CreateCustomer(provider, null)); [Theory, BitAutoData] public async Task CreateCustomer_MissingCountry_ContactSupport( SutProvider sutProvider, Provider provider, TaxInfo taxInfo) { taxInfo.BillingAddressCountry = null; await ThrowsContactSupportAsync(() => sutProvider.Sut.CreateCustomer(provider, taxInfo)); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .CustomerGetAsync(Arg.Any(), Arg.Any()); } [Theory, BitAutoData] public async Task CreateCustomer_MissingPostalCode_ContactSupport( SutProvider sutProvider, Provider provider, TaxInfo taxInfo) { taxInfo.BillingAddressCountry = null; await ThrowsContactSupportAsync(() => sutProvider.Sut.CreateCustomer(provider, taxInfo)); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .CustomerGetAsync(Arg.Any(), Arg.Any()); } [Theory, BitAutoData] public async Task CreateCustomer_Success( SutProvider sutProvider, Provider provider, TaxInfo taxInfo) { provider.Name = "MSP"; taxInfo.BillingAddressCountry = "AD"; var stripeAdapter = sutProvider.GetDependency(); 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(new Customer { Id = "customer_id", Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } }); await sutProvider.Sut.CreateCustomer(provider, taxInfo); await stripeAdapter.Received(1).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)); await sutProvider.GetDependency() .ReplaceAsync(Arg.Is(p => p.GatewayCustomerId == "customer_id")); } #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 ThrowsContactSupportAsync(() => 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 ThrowsContactSupportAsync( () => sutProvider.Sut.GetAssignedSeatTotalForPlanOrThrow(providerId, PlanType.TeamsMonthly), internalMessage: "Consolidated billing does not support reseller-type providers"); } [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 GetConsolidatedBillingSubscription [Theory, BitAutoData] public async Task GetConsolidatedBillingSubscription_NullProvider_ThrowsArgumentNullException( SutProvider sutProvider) => await Assert.ThrowsAsync(() => sutProvider.Sut.GetConsolidatedBillingSubscription(null)); [Theory, BitAutoData] public async Task GetConsolidatedBillingSubscription_NullSubscription_ReturnsNull( SutProvider sutProvider, Provider provider) { var consolidatedBillingSubscription = await sutProvider.Sut.GetConsolidatedBillingSubscription(provider); Assert.Null(consolidatedBillingSubscription); await sutProvider.GetDependency().Received(1).GetSubscription( provider, Arg.Is( options => options.Expand.Count == 2 && options.Expand.First() == "customer" && options.Expand.Last() == "test_clock")); } [Theory, BitAutoData] public async Task GetConsolidatedBillingSubscription_Active_NoSuspension_Success( SutProvider sutProvider, Provider provider) { var subscriberService = sutProvider.GetDependency(); var subscription = new Subscription { Status = "active" }; subscriberService.GetSubscription(provider, Arg.Is( options => options.Expand.Count == 2 && options.Expand.First() == "customer" && options.Expand.Last() == "test_clock")).Returns(subscription); var providerPlanRepository = sutProvider.GetDependency(); var enterprisePlan = new ProviderPlan { Id = Guid.NewGuid(), ProviderId = provider.Id, PlanType = PlanType.EnterpriseMonthly, SeatMinimum = 100, PurchasedSeats = 0, AllocatedSeats = 0 }; var teamsPlan = new ProviderPlan { Id = Guid.NewGuid(), ProviderId = provider.Id, PlanType = PlanType.TeamsMonthly, SeatMinimum = 50, PurchasedSeats = 10, AllocatedSeats = 60 }; var providerPlans = new List { enterprisePlan, teamsPlan, }; providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); var taxInformation = new TaxInformationDTO("US", "12345", "123456789", "123 Example St.", null, "Example Town", "NY"); subscriberService.GetTaxInformation(provider).Returns(taxInformation); var (gotProviderPlans, gotSubscription, gotTaxInformation, gotSuspension) = await sutProvider.Sut.GetConsolidatedBillingSubscription(provider); Assert.Equal(2, gotProviderPlans.Count); var configuredEnterprisePlan = gotProviderPlans.FirstOrDefault(configuredPlan => configuredPlan.PlanType == PlanType.EnterpriseMonthly); var configuredTeamsPlan = gotProviderPlans.FirstOrDefault(configuredPlan => configuredPlan.PlanType == PlanType.TeamsMonthly); Compare(enterprisePlan, configuredEnterprisePlan); Compare(teamsPlan, configuredTeamsPlan); Assert.Equivalent(subscription, gotSubscription); Assert.Equivalent(taxInformation, gotTaxInformation); Assert.Null(gotSuspension); 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); } } [Theory, BitAutoData] public async Task GetConsolidatedBillingSubscription_PastDue_HasSuspension_Success( SutProvider sutProvider, Provider provider) { var subscriberService = sutProvider.GetDependency(); var subscription = new Subscription { Id = "subscription_id", Status = "past_due", CollectionMethod = "send_invoice" }; subscriberService.GetSubscription(provider, Arg.Is( options => options.Expand.Count == 2 && options.Expand.First() == "customer" && options.Expand.Last() == "test_clock")).Returns(subscription); var providerPlanRepository = sutProvider.GetDependency(); var enterprisePlan = new ProviderPlan { Id = Guid.NewGuid(), ProviderId = provider.Id, PlanType = PlanType.EnterpriseMonthly, SeatMinimum = 100, PurchasedSeats = 0, AllocatedSeats = 0 }; var teamsPlan = new ProviderPlan { Id = Guid.NewGuid(), ProviderId = provider.Id, PlanType = PlanType.TeamsMonthly, SeatMinimum = 50, PurchasedSeats = 10, AllocatedSeats = 60 }; var providerPlans = new List { enterprisePlan, teamsPlan, }; providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); var taxInformation = new TaxInformationDTO("US", "12345", "123456789", "123 Example St.", null, "Example Town", "NY"); subscriberService.GetTaxInformation(provider).Returns(taxInformation); var stripeAdapter = sutProvider.GetDependency(); var openInvoice = new Invoice { Id = "invoice_id", Status = "open", DueDate = new DateTime(2024, 6, 1), Created = new DateTime(2024, 5, 1), PeriodEnd = new DateTime(2024, 6, 1) }; stripeAdapter.InvoiceSearchAsync(Arg.Is(options => options.Query == $"subscription:'{subscription.Id}' status:'open'")) .Returns([openInvoice]); var (gotProviderPlans, gotSubscription, gotTaxInformation, gotSuspension) = await sutProvider.Sut.GetConsolidatedBillingSubscription(provider); Assert.Equal(2, gotProviderPlans.Count); Assert.Equivalent(subscription, gotSubscription); Assert.Equivalent(taxInformation, gotTaxInformation); Assert.NotNull(gotSuspension); Assert.Equal(openInvoice.DueDate.Value.AddDays(30), gotSuspension.SuspensionDate); Assert.Equal(openInvoice.PeriodEnd, gotSuspension.UnpaidPeriodEndDate); Assert.Equal(30, gotSuspension.GracePeriod); } #endregion #region StartSubscription [Theory, BitAutoData] public async Task StartSubscription_NullProvider_ThrowsArgumentNullException( SutProvider sutProvider) => await Assert.ThrowsAsync(() => sutProvider.Sut.StartSubscription(null)); [Theory, BitAutoData] public async Task StartSubscription_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 ThrowsContactSupportAsync(() => sutProvider.Sut.StartSubscription(provider)); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .SubscriptionCreateAsync(Arg.Any()); } [Theory, BitAutoData] public async Task StartSubscription_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 ThrowsContactSupportAsync(() => sutProvider.Sut.StartSubscription(provider)); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .SubscriptionCreateAsync(Arg.Any()); } [Theory, BitAutoData] public async Task StartSubscription_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 ThrowsContactSupportAsync(() => sutProvider.Sut.StartSubscription(provider)); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .SubscriptionCreateAsync(Arg.Any()); } [Theory, BitAutoData] public async Task StartSubscription_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 ThrowsContactSupportAsync(() => sutProvider.Sut.StartSubscription(provider)); await sutProvider.GetDependency().Received(1) .ReplaceAsync(Arg.Is(p => p.GatewaySubscriptionId == "subscription_id")); } [Theory, BitAutoData] public async Task StartSubscription_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); 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(new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active }); await sutProvider.Sut.StartSubscription(provider); await sutProvider.GetDependency().Received(1) .ReplaceAsync(Arg.Is(p => p.GatewaySubscriptionId == "subscription_id")); } #endregion }