using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Billing.Subscriptions.Models; using Bit.Core.Entities; using Bit.Core.Exceptions; using Microsoft.Extensions.Logging; using OneOf; using Stripe; namespace Bit.Core.Billing.Subscriptions.Queries; using static StripeConstants; using static Utilities; public interface IGetBitwardenSubscriptionQuery { /// /// Retrieves detailed subscription information for a user, including subscription status, /// cart items, discounts, and billing details. /// /// The user whose subscription information to retrieve. /// /// A containing the subscription details, or null if no /// subscription is found or the subscription status is not recognized. /// /// /// Currently only supports subscribers. Future versions will support all /// types (User and Organization). /// Task Run(User user); } public class GetBitwardenSubscriptionQuery( ILogger logger, IPricingClient pricingClient, IStripeAdapter stripeAdapter) : IGetBitwardenSubscriptionQuery { public async Task Run(User user) { var subscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, new SubscriptionGetOptions { Expand = [ "customer.discount.coupon.applies_to", "discounts.coupon.applies_to", "items.data.price.product", "test_clock" ] }); var cart = await GetPremiumCartAsync(subscription); var baseSubscription = new BitwardenSubscription { Status = subscription.Status, Cart = cart, Storage = user }; switch (subscription.Status) { case SubscriptionStatus.Incomplete: case SubscriptionStatus.IncompleteExpired: return baseSubscription with { Suspension = subscription.Created.AddHours(23), GracePeriod = 1 }; case SubscriptionStatus.Trialing: case SubscriptionStatus.Active: return baseSubscription with { NextCharge = subscription.GetCurrentPeriodEnd(), CancelAt = subscription.CancelAt }; case SubscriptionStatus.PastDue: case SubscriptionStatus.Unpaid: var suspension = await GetSubscriptionSuspensionAsync(stripeAdapter, subscription); if (suspension == null) { return baseSubscription; } return baseSubscription with { Suspension = suspension.SuspensionDate, GracePeriod = suspension.GracePeriod }; case SubscriptionStatus.Canceled: return baseSubscription with { Canceled = subscription.CanceledAt }; default: { logger.LogError("Subscription ({SubscriptionID}) has an unmanaged status ({Status})", subscription.Id, subscription.Status); throw new ConflictException("Subscription is in an invalid state. Please contact support for assistance."); } } } private async Task GetPremiumCartAsync( Subscription subscription) { var plans = await pricingClient.ListPremiumPlans(); var passwordManagerSeatsItem = subscription.Items.FirstOrDefault(item => plans.Any(plan => plan.Seat.StripePriceId == item.Price.Id)); if (passwordManagerSeatsItem == null) { throw new ConflictException("Premium subscription does not have a Password Manager line item."); } var additionalStorageItem = subscription.Items.FirstOrDefault(item => plans.Any(plan => plan.Storage.StripePriceId == item.Price.Id)); var (cartLevelDiscount, productLevelDiscounts) = GetStripeDiscounts(subscription); var passwordManagerSeats = new CartItem { TranslationKey = "premiumMembership", Quantity = passwordManagerSeatsItem.Quantity, Cost = GetCost(passwordManagerSeatsItem), Discount = productLevelDiscounts.FirstOrDefault(discount => discount.AppliesTo(passwordManagerSeatsItem)) }; var additionalStorage = additionalStorageItem != null ? new CartItem { TranslationKey = "additionalStorageGB", Quantity = additionalStorageItem.Quantity, Cost = GetCost(additionalStorageItem), Discount = productLevelDiscounts.FirstOrDefault(discount => discount.AppliesTo(additionalStorageItem)) } : null; var estimatedTax = await EstimateTaxAsync(subscription); return new Cart { PasswordManager = new PasswordManagerCartItems { Seats = passwordManagerSeats, AdditionalStorage = additionalStorage }, Cadence = PlanCadenceType.Annually, Discount = cartLevelDiscount, EstimatedTax = estimatedTax }; } #region Utilities private async Task EstimateTaxAsync(Subscription subscription) { try { var invoice = await stripeAdapter.CreateInvoicePreviewAsync(new InvoiceCreatePreviewOptions { Customer = subscription.Customer.Id, Subscription = subscription.Id }); return GetCost(invoice.TotalTaxes); } catch (StripeException stripeException) when (stripeException.StripeError.Code == ErrorCodes.InvoiceUpcomingNone) { return 0; } } private static decimal GetCost(OneOf> value) => value.Match( item => (item.Price.UnitAmountDecimal ?? 0) / 100M, taxes => taxes.Sum(invoiceTotalTax => invoiceTotalTax.Amount) / 100M); private static (Discount? CartLevel, List ProductLevel) GetStripeDiscounts( Subscription subscription) { var discounts = new List(); if (subscription.Customer.Discount.IsValid()) { discounts.Add(subscription.Customer.Discount); } discounts.AddRange(subscription.Discounts.Where(discount => discount.IsValid())); var cartLevel = new List(); var productLevel = new List(); foreach (var discount in discounts) { switch (discount) { case { Coupon.AppliesTo.Products: null or { Count: 0 } }: cartLevel.Add(discount); break; case { Coupon.AppliesTo.Products.Count: > 0 }: productLevel.Add(discount); break; } } return (cartLevel.FirstOrDefault(), productLevel); } #endregion }