diff --git a/src/Api/Models/Response/SubscriptionResponseModel.cs b/src/Api/Models/Response/SubscriptionResponseModel.cs index 7ba2b857eb..cca4f8ae72 100644 --- a/src/Api/Models/Response/SubscriptionResponseModel.cs +++ b/src/Api/Models/Response/SubscriptionResponseModel.cs @@ -75,6 +75,10 @@ public class BillingSubscription { Items = sub.Items.Select(i => new BillingSubscriptionItem(i)); } + CollectionMethod = sub.CollectionMethod; + SuspensionDate = sub.SuspensionDate; + UnpaidPeriodEndDate = sub.UnpaidPeriodEndDate; + GracePeriod = sub.GracePeriod; } public DateTime? TrialStartDate { get; set; } @@ -86,6 +90,10 @@ public class BillingSubscription public string Status { get; set; } public bool Cancelled { get; set; } public IEnumerable Items { get; set; } = new List(); + public string CollectionMethod { get; set; } + public DateTime? SuspensionDate { get; set; } + public DateTime? UnpaidPeriodEndDate { get; set; } + public int? GracePeriod { get; set; } public class BillingSubscriptionItem { diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 2b8ff33211..8f5cc0773b 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -131,6 +131,7 @@ public static class FeatureFlagKeys public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email"; public const string ShowPaymentMethodWarningBanners = "show-payment-method-warning-banners"; public const string EnableConsolidatedBilling = "enable-consolidated-billing"; + public const string AC1795_UpdatedSubscriptionStatusSection = "AC-1795_updated-subscription-status-section"; public static List GetAllKeys() { diff --git a/src/Core/Models/Business/SubscriptionInfo.cs b/src/Core/Models/Business/SubscriptionInfo.cs index 23f8f95278..7bb5bddbc8 100644 --- a/src/Core/Models/Business/SubscriptionInfo.cs +++ b/src/Core/Models/Business/SubscriptionInfo.cs @@ -43,6 +43,9 @@ public class SubscriptionInfo Items = sub.Items.Data.Select(i => new BillingSubscriptionItem(i)); } CollectionMethod = sub.CollectionMethod; + GracePeriod = sub.CollectionMethod == "charge_automatically" + ? 14 + : 30; } public DateTime? TrialStartDate { get; set; } @@ -56,6 +59,9 @@ public class SubscriptionInfo public bool Cancelled { get; set; } public IEnumerable Items { get; set; } = new List(); public string CollectionMethod { get; set; } + public DateTime? SuspensionDate { get; set; } + public DateTime? UnpaidPeriodEndDate { get; set; } + public int GracePeriod { get; set; } public class BillingSubscriptionItem { diff --git a/src/Core/Services/IStripeAdapter.cs b/src/Core/Services/IStripeAdapter.cs index 073d5cdacd..908dc2c0d8 100644 --- a/src/Core/Services/IStripeAdapter.cs +++ b/src/Core/Services/IStripeAdapter.cs @@ -1,4 +1,5 @@ using Bit.Core.Models.BitStripe; +using Stripe; namespace Bit.Core.Services; @@ -16,6 +17,7 @@ public interface IStripeAdapter Task InvoiceUpcomingAsync(Stripe.UpcomingInvoiceOptions options); Task InvoiceGetAsync(string id, Stripe.InvoiceGetOptions options); Task> InvoiceListAsync(StripeInvoiceListOptions options); + Task> InvoiceSearchAsync(InvoiceSearchOptions options); Task InvoiceUpdateAsync(string id, Stripe.InvoiceUpdateOptions options); Task InvoiceFinalizeInvoiceAsync(string id, Stripe.InvoiceFinalizeOptions options); Task InvoiceSendInvoiceAsync(string id, Stripe.InvoiceSendOptions options); diff --git a/src/Core/Services/Implementations/StripeAdapter.cs b/src/Core/Services/Implementations/StripeAdapter.cs index ef8d13aea8..a7109252d4 100644 --- a/src/Core/Services/Implementations/StripeAdapter.cs +++ b/src/Core/Services/Implementations/StripeAdapter.cs @@ -1,4 +1,5 @@ using Bit.Core.Models.BitStripe; +using Stripe; namespace Bit.Core.Services; @@ -103,6 +104,9 @@ public class StripeAdapter : IStripeAdapter return invoices; } + public async Task> InvoiceSearchAsync(InvoiceSearchOptions options) + => (await _invoiceService.SearchAsync(options)).Data; + public Task InvoiceUpdateAsync(string id, Stripe.InvoiceUpdateOptions options) { return _invoiceService.UpdateAsync(id, options); diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index a9e688fcbf..234543a8f6 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -1603,10 +1603,25 @@ public class StripePaymentService : IPaymentService return subscriptionInfo; } - var sub = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId); + var sub = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, new SubscriptionGetOptions + { + Expand = ["test_clock"] + }); + if (sub != null) { subscriptionInfo.Subscription = new SubscriptionInfo.BillingSubscription(sub); + + if (_featureService.IsEnabled(FeatureFlagKeys.AC1795_UpdatedSubscriptionStatusSection)) + { + var (suspensionDate, unpaidPeriodEndDate) = await GetSuspensionDateAsync(sub); + + if (suspensionDate.HasValue && unpaidPeriodEndDate.HasValue) + { + subscriptionInfo.Subscription.SuspensionDate = suspensionDate; + subscriptionInfo.Subscription.UnpaidPeriodEndDate = unpaidPeriodEndDate; + } + } } if (sub is { CanceledAt: not null } || string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId)) @@ -1923,4 +1938,45 @@ 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); + } + } }