diff --git a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs index 0e5ce8dc44..f54ecf5a6e 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs @@ -1,4 +1,5 @@ -using Bit.Core.AdminConsole.Entities; +using Bit.Core; +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; @@ -30,7 +31,8 @@ public class ProviderBillingService( IProviderPlanRepository providerPlanRepository, IProviderRepository providerRepository, IStripeAdapter stripeAdapter, - ISubscriberService subscriberService) : IProviderBillingService + ISubscriberService subscriberService, + IFeatureService featureService) : IProviderBillingService { public async Task AssignSeatsToClientOrganization( Provider provider, @@ -248,6 +250,18 @@ 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 @@ -257,7 +271,9 @@ public class ProviderBillingService( return new ConsolidatedBillingSubscriptionDTO( configuredProviderPlans, - subscription); + subscription, + subscriptionSuspensionDate, + subscriptionUnpaidPeriodEndDate); } public async Task ScaleSeats( diff --git a/src/Api/Billing/Models/Responses/ConsolidatedBillingSubscriptionResponse.cs b/src/Api/Billing/Models/Responses/ConsolidatedBillingSubscriptionResponse.cs index b9f761b364..6ada6284e7 100644 --- a/src/Api/Billing/Models/Responses/ConsolidatedBillingSubscriptionResponse.cs +++ b/src/Api/Billing/Models/Responses/ConsolidatedBillingSubscriptionResponse.cs @@ -7,6 +7,10 @@ public record ConsolidatedBillingSubscriptionResponse( string Status, DateTime CurrentPeriodEndDate, decimal? DiscountPercentage, + string CollectionMethod, + DateTime? UnpaidPeriodEndDate, + int? GracePeriod, + DateTime? SuspensionDate, IEnumerable Plans) { private const string _annualCadence = "Annual"; @@ -15,7 +19,7 @@ public record ConsolidatedBillingSubscriptionResponse( public static ConsolidatedBillingSubscriptionResponse From( ConsolidatedBillingSubscriptionDTO consolidatedBillingSubscription) { - var (providerPlans, subscription) = consolidatedBillingSubscription; + var (providerPlans, subscription, suspensionDate, unpaidPeriodEndDate) = consolidatedBillingSubscription; var providerPlansDTO = providerPlans .Select(providerPlan => @@ -31,11 +35,15 @@ public record ConsolidatedBillingSubscriptionResponse( cost, 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, providerPlansDTO); } } diff --git a/src/Core/Billing/Models/ConsolidatedBillingSubscriptionDTO.cs b/src/Core/Billing/Models/ConsolidatedBillingSubscriptionDTO.cs index 1ebd264df8..b378c32104 100644 --- a/src/Core/Billing/Models/ConsolidatedBillingSubscriptionDTO.cs +++ b/src/Core/Billing/Models/ConsolidatedBillingSubscriptionDTO.cs @@ -4,4 +4,6 @@ namespace Bit.Core.Billing.Models; public record ConsolidatedBillingSubscriptionDTO( List ProviderPlans, - Subscription Subscription); + Subscription Subscription, + DateTime? SuspensionDate, + DateTime? UnpaidPeriodEndDate); diff --git a/src/Core/Services/IPaymentService.cs b/src/Core/Services/IPaymentService.cs index e3146c4398..2496d623f3 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Services/IPaymentService.cs @@ -55,4 +55,5 @@ public interface IPaymentService int additionalServiceAccount); Task RisksSubscriptionFailure(Organization organization); Task HasSecretsManagerStandalone(Organization organization); + Task<(DateTime?, DateTime?)> GetSuspensionDateAsync(Stripe.Subscription subscription); } diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index ad89e20c2f..1e00118247 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -1820,6 +1820,47 @@ public class StripePaymentService : IPaymentService return customer?.Discount?.Coupon?.Id == SecretsManagerStandaloneDiscountId; } + public async Task<(DateTime?, DateTime?)> GetSuspensionDateAsync(Subscription subscription) + { + if (subscription.Status is not "past_due" && subscription.Status is not "unpaid") + { + return (null, null); + } + + var openInvoices = await _stripeAdapter.InvoiceSearchAsync(new InvoiceSearchOptions + { + Query = $"subscription:'{subscription.Id}' status:'open'" + }); + + if (openInvoices.Count == 0) + { + return (null, 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); + + return (firstOverdueInvoice?.Created.AddDays(14), firstOverdueInvoice?.PeriodEnd); + } + case "send_invoice": + { + var firstOverdueInvoice = openInvoices + .Where(invoice => invoice.DueDate < currentDate) + .MinBy(invoice => invoice.Created); + + return (firstOverdueInvoice?.DueDate?.AddDays(30), firstOverdueInvoice?.PeriodEnd); + } + default: return (null, null); + } + } + private PaymentMethod GetLatestCardPaymentMethod(string customerId) { var cardPaymentMethods = _stripeAdapter.PaymentMethodListAutoPaging( @@ -1962,45 +2003,4 @@ public class StripePaymentService : IPaymentService ? subscriberName : subscriberName[..30]; } - - private async Task<(DateTime?, DateTime?)> GetSuspensionDateAsync(Subscription subscription) - { - if (subscription.Status is not "past_due" && subscription.Status is not "unpaid") - { - return (null, null); - } - - var openInvoices = await _stripeAdapter.InvoiceSearchAsync(new InvoiceSearchOptions - { - Query = $"subscription:'{subscription.Id}' status:'open'" - }); - - if (openInvoices.Count == 0) - { - return (null, 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); - - return (firstOverdueInvoice?.Created.AddDays(14), firstOverdueInvoice?.PeriodEnd); - } - case "send_invoice": - { - var firstOverdueInvoice = openInvoices - .Where(invoice => invoice.DueDate < currentDate) - .MinBy(invoice => invoice.Created); - - return (firstOverdueInvoice?.DueDate?.AddDays(30), firstOverdueInvoice?.PeriodEnd); - } - default: return (null, null); - } - } } diff --git a/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs b/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs index 4c1bf51728..90f9938783 100644 --- a/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs @@ -282,9 +282,14 @@ public class ProviderBillingControllerTests Customer = new Customer { Discount = new Discount { Coupon = new Coupon { PercentOff = 10 } } } }; + DateTime? SuspensionDate = new DateTime(); + DateTime? UnpaidPeriodEndDate = new DateTime(); + var gracePeriod = 30; var consolidatedBillingSubscription = new ConsolidatedBillingSubscriptionDTO( configuredProviderPlans, - subscription); + subscription, + SuspensionDate, + UnpaidPeriodEndDate); sutProvider.GetDependency().GetConsolidatedBillingSubscription(provider) .Returns(consolidatedBillingSubscription); @@ -298,6 +303,10 @@ public class ProviderBillingControllerTests 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); var teamsPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); var providerTeamsPlan = response.Plans.FirstOrDefault(plan => plan.PlanName == teamsPlan.Name);