From e8e725c389b4f5c43c13f8e760c5c54a925263ee Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Wed, 26 Jun 2024 09:08:18 -0400 Subject: [PATCH] [AC-2795] Add account credit & tax information to provider subscription (#4276) * Add account credit, suspension and tax information to subscription response * Run dotnet format' --- .../Billing/ProviderBillingService.cs | 29 +---- .../Billing/ProviderBillingServiceTests.cs | 103 ++++++++++++++++-- ...ConsolidatedBillingSubscriptionResponse.cs | 22 ++-- .../ConsolidatedBillingSubscriptionDTO.cs | 4 +- .../Models/SubscriptionSuspensionDTO.cs | 6 + src/Core/Billing/Utilities.cs | 69 +++++++++++- .../ProviderBillingControllerTests.cs | 48 +++++--- 7 files changed, 220 insertions(+), 61 deletions(-) create mode 100644 src/Core/Billing/Models/SubscriptionSuspensionDTO.cs diff --git a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs index 0fae9e8b2..6f52f59de 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs @@ -1,6 +1,5 @@ using System.Globalization; using Bit.Commercial.Core.Billing.Models; -using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; @@ -29,7 +28,6 @@ namespace Bit.Commercial.Core.Billing; public class ProviderBillingService( ICurrentContext currentContext, - IFeatureService featureService, IGlobalSettings globalSettings, ILogger logger, IOrganizationRepository organizationRepository, @@ -272,13 +270,6 @@ public class ProviderBillingService( { ArgumentNullException.ThrowIfNull(provider); - if (provider.Type == ProviderType.Reseller) - { - logger.LogError("Consolidated billing subscription cannot be retrieved for reseller-type provider ({ID})", provider.Id); - - throw ContactSupport("Consolidated billing does not support reseller-type providers"); - } - var subscription = await subscriberService.GetSubscription(provider, new SubscriptionGetOptions { Expand = ["customer", "test_clock"] @@ -289,18 +280,6 @@ public class ProviderBillingService( return null; } - DateTime? subscriptionSuspensionDate = null; - DateTime? subscriptionUnpaidPeriodEndDate = null; - if (featureService.IsEnabled(FeatureFlagKeys.AC1795_UpdatedSubscriptionStatusSection)) - { - var (suspensionDate, unpaidPeriodEndDate) = await paymentService.GetSuspensionDateAsync(subscription); - if (suspensionDate.HasValue && unpaidPeriodEndDate.HasValue) - { - subscriptionSuspensionDate = suspensionDate; - subscriptionUnpaidPeriodEndDate = unpaidPeriodEndDate; - } - } - var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id); var configuredProviderPlans = providerPlans @@ -308,11 +287,15 @@ public class ProviderBillingService( .Select(ConfiguredProviderPlanDTO.From) .ToList(); + var taxInformation = await subscriberService.GetTaxInformation(provider); + + var suspension = await GetSuspensionAsync(stripeAdapter, subscription); + return new ConsolidatedBillingSubscriptionDTO( configuredProviderPlans, subscription, - subscriptionSuspensionDate, - subscriptionUnpaidPeriodEndDate); + taxInformation, + suspension); } public async Task ScaleSeats( diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs index d91553cab..caebde363 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs @@ -857,13 +857,16 @@ public class ProviderBillingServiceTests } [Theory, BitAutoData] - public async Task GetConsolidatedBillingSubscription_Success( + public async Task GetConsolidatedBillingSubscription_Active_NoSuspension_Success( SutProvider sutProvider, Provider provider) { var subscriberService = sutProvider.GetDependency(); - var subscription = new Subscription(); + 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); @@ -894,26 +897,33 @@ public class ProviderBillingServiceTests providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); - var consolidatedBillingSubscription = await sutProvider.Sut.GetConsolidatedBillingSubscription(provider); + var taxInformation = + new TaxInformationDTO("US", "12345", "123456789", "123 Example St.", null, "Example Town", "NY"); - Assert.NotNull(consolidatedBillingSubscription); + subscriberService.GetTaxInformation(provider).Returns(taxInformation); - Assert.Equivalent(consolidatedBillingSubscription.Subscription, subscription); + var (gotProviderPlans, gotSubscription, gotTaxInformation, gotSuspension) = await sutProvider.Sut.GetConsolidatedBillingSubscription(provider); - Assert.Equal(2, consolidatedBillingSubscription.ProviderPlans.Count); + Assert.Equal(2, gotProviderPlans.Count); var configuredEnterprisePlan = - consolidatedBillingSubscription.ProviderPlans.FirstOrDefault(configuredPlan => + gotProviderPlans.FirstOrDefault(configuredPlan => configuredPlan.PlanType == PlanType.EnterpriseMonthly); var configuredTeamsPlan = - consolidatedBillingSubscription.ProviderPlans.FirstOrDefault(configuredPlan => + 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) @@ -927,6 +937,83 @@ public class ProviderBillingServiceTests } } + [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 diff --git a/src/Api/Billing/Models/Responses/ConsolidatedBillingSubscriptionResponse.cs b/src/Api/Billing/Models/Responses/ConsolidatedBillingSubscriptionResponse.cs index ddfef1a3a..0e1656913 100644 --- a/src/Api/Billing/Models/Responses/ConsolidatedBillingSubscriptionResponse.cs +++ b/src/Api/Billing/Models/Responses/ConsolidatedBillingSubscriptionResponse.cs @@ -8,11 +8,11 @@ public record ConsolidatedBillingSubscriptionResponse( DateTime CurrentPeriodEndDate, decimal? DiscountPercentage, string CollectionMethod, - DateTime? UnpaidPeriodEndDate, - int? GracePeriod, - DateTime? SuspensionDate, + IEnumerable Plans, + long AccountCredit, + TaxInformationDTO TaxInformation, DateTime? CancelAt, - IEnumerable Plans) + SubscriptionSuspensionDTO Suspension) { private const string _annualCadence = "Annual"; private const string _monthlyCadence = "Monthly"; @@ -20,9 +20,9 @@ public record ConsolidatedBillingSubscriptionResponse( public static ConsolidatedBillingSubscriptionResponse From( ConsolidatedBillingSubscriptionDTO consolidatedBillingSubscription) { - var (providerPlans, subscription, suspensionDate, unpaidPeriodEndDate) = consolidatedBillingSubscription; + var (providerPlans, subscription, taxInformation, suspension) = consolidatedBillingSubscription; - var providerPlansDTO = providerPlans + var providerPlanResponses = providerPlans .Select(providerPlan => { var plan = StaticStore.GetPlan(providerPlan.PlanType); @@ -37,18 +37,16 @@ public record ConsolidatedBillingSubscriptionResponse( cadence); }); - var gracePeriod = subscription.CollectionMethod == "charge_automatically" ? 14 : 30; - return new ConsolidatedBillingSubscriptionResponse( subscription.Status, subscription.CurrentPeriodEnd, subscription.Customer?.Discount?.Coupon?.PercentOff, subscription.CollectionMethod, - unpaidPeriodEndDate, - gracePeriod, - suspensionDate, + providerPlanResponses, + subscription.Customer?.Balance ?? 0, + taxInformation, subscription.CancelAt, - providerPlansDTO); + suspension); } } diff --git a/src/Core/Billing/Models/ConsolidatedBillingSubscriptionDTO.cs b/src/Core/Billing/Models/ConsolidatedBillingSubscriptionDTO.cs index b378c3210..4b2f46adc 100644 --- a/src/Core/Billing/Models/ConsolidatedBillingSubscriptionDTO.cs +++ b/src/Core/Billing/Models/ConsolidatedBillingSubscriptionDTO.cs @@ -5,5 +5,5 @@ namespace Bit.Core.Billing.Models; public record ConsolidatedBillingSubscriptionDTO( List ProviderPlans, Subscription Subscription, - DateTime? SuspensionDate, - DateTime? UnpaidPeriodEndDate); + TaxInformationDTO TaxInformation, + SubscriptionSuspensionDTO Suspension); diff --git a/src/Core/Billing/Models/SubscriptionSuspensionDTO.cs b/src/Core/Billing/Models/SubscriptionSuspensionDTO.cs new file mode 100644 index 000000000..ac0261f2c --- /dev/null +++ b/src/Core/Billing/Models/SubscriptionSuspensionDTO.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.Billing.Models; + +public record SubscriptionSuspensionDTO( + DateTime SuspensionDate, + DateTime UnpaidPeriodEndDate, + int GracePeriod); diff --git a/src/Core/Billing/Utilities.cs b/src/Core/Billing/Utilities.cs index 2b06f1ea6..2c5ad8547 100644 --- a/src/Core/Billing/Utilities.cs +++ b/src/Core/Billing/Utilities.cs @@ -1,4 +1,8 @@ -namespace Bit.Core.Billing; +using Bit.Core.Billing.Models; +using Bit.Core.Services; +using Stripe; + +namespace Bit.Core.Billing; public static class Utilities { @@ -8,4 +12,67 @@ public static class Utilities string internalMessage = null, Exception innerException = null) => new("Something went wrong with your request. Please contact support.", internalMessage, innerException); + + public static async Task GetSuspensionAsync( + IStripeAdapter stripeAdapter, + Subscription subscription) + { + if (subscription.Status is not "past_due" && subscription.Status is not "unpaid") + { + return null; + } + + var openInvoices = await stripeAdapter.InvoiceSearchAsync(new InvoiceSearchOptions + { + Query = $"subscription:'{subscription.Id}' status:'open'" + }); + + if (openInvoices.Count == 0) + { + return null; + } + + var currentDate = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow; + + switch (subscription.CollectionMethod) + { + case "charge_automatically": + { + var firstOverdueInvoice = openInvoices + .Where(invoice => invoice.PeriodEnd < currentDate && invoice.Attempted) + .MinBy(invoice => invoice.Created); + + if (firstOverdueInvoice == null) + { + return null; + } + + const int gracePeriod = 14; + + return new SubscriptionSuspensionDTO( + firstOverdueInvoice.Created.AddDays(gracePeriod), + firstOverdueInvoice.PeriodEnd, + gracePeriod); + } + case "send_invoice": + { + var firstOverdueInvoice = openInvoices + .Where(invoice => invoice.DueDate < currentDate) + .MinBy(invoice => invoice.Created); + + if (firstOverdueInvoice?.DueDate == null) + { + return null; + } + + const int gracePeriod = 30; + + return new SubscriptionSuspensionDTO( + firstOverdueInvoice.DueDate.Value.AddDays(gracePeriod), + firstOverdueInvoice.PeriodEnd, + gracePeriod); + } + default: return null; + } + } } diff --git a/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs b/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs index c39b058b6..2c4245bd6 100644 --- a/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs @@ -385,19 +385,34 @@ public class ProviderBillingControllerTests var subscription = new Subscription { - Status = "active", - CurrentPeriodEnd = new DateTime(2025, 1, 1), - Customer = new Customer { Discount = new Discount { Coupon = new Coupon { PercentOff = 10 } } } + Status = "unpaid", + CurrentPeriodEnd = new DateTime(2024, 6, 30), + Customer = new Customer + { + Balance = 100000, + Discount = new Discount + { + Coupon = new Coupon + { + PercentOff = 10 + } + } + } }; - DateTime? SuspensionDate = new DateTime(); - DateTime? UnpaidPeriodEndDate = new DateTime(); - var gracePeriod = 30; + var taxInformation = + new TaxInformationDTO("US", "12345", "123456789", "123 Example St.", null, "Example Town", "NY"); + + var suspension = new SubscriptionSuspensionDTO( + new DateTime(2024, 7, 30), + new DateTime(2024, 5, 30), + 30); + var consolidatedBillingSubscription = new ConsolidatedBillingSubscriptionDTO( configuredProviderPlans, subscription, - SuspensionDate, - UnpaidPeriodEndDate); + taxInformation, + suspension); sutProvider.GetDependency().GetConsolidatedBillingSubscription(provider) .Returns(consolidatedBillingSubscription); @@ -408,13 +423,10 @@ public class ProviderBillingControllerTests var response = ((Ok)result).Value; - Assert.Equal(response.Status, subscription.Status); - Assert.Equal(response.CurrentPeriodEndDate, subscription.CurrentPeriodEnd); - Assert.Equal(response.DiscountPercentage, subscription.Customer!.Discount!.Coupon!.PercentOff); - Assert.Equal(response.CollectionMethod, subscription.CollectionMethod); - Assert.Equal(response.UnpaidPeriodEndDate, UnpaidPeriodEndDate); - Assert.Equal(response.GracePeriod, gracePeriod); - Assert.Equal(response.SuspensionDate, SuspensionDate); + Assert.Equal(subscription.Status, response.Status); + Assert.Equal(subscription.CurrentPeriodEnd, response.CurrentPeriodEndDate); + Assert.Equal(subscription.Customer!.Discount!.Coupon!.PercentOff, response.DiscountPercentage); + Assert.Equal(subscription.CollectionMethod, response.CollectionMethod); var teamsPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); var providerTeamsPlan = response.Plans.FirstOrDefault(plan => plan.PlanName == teamsPlan.Name); @@ -433,7 +445,13 @@ public class ProviderBillingControllerTests Assert.Equal(90, providerEnterprisePlan.AssignedSeats); Assert.Equal(100 * enterprisePlan.PasswordManager.ProviderPortalSeatPrice, providerEnterprisePlan.Cost); Assert.Equal("Monthly", providerEnterprisePlan.Cadence); + + Assert.Equal(100000, response.AccountCredit); + Assert.Equal(taxInformation, response.TaxInformation); + Assert.Null(response.CancelAt); + Assert.Equal(suspension, response.Suspension); } + #endregion #region GetTaxInformationAsync