using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Billing.Subscriptions.Models; using Bit.Core.Billing.Subscriptions.Queries; using Bit.Core.Entities; using Bit.Core.Exceptions; using Microsoft.Extensions.Logging; using NSubstitute; using NSubstitute.ExceptionExtensions; using Stripe; using Xunit; namespace Bit.Core.Test.Billing.Subscriptions.Queries; using static StripeConstants; public class GetBitwardenSubscriptionQueryTests { private readonly ILogger _logger = Substitute.For>(); private readonly IPricingClient _pricingClient = Substitute.For(); private readonly IStripeAdapter _stripeAdapter = Substitute.For(); private readonly GetBitwardenSubscriptionQuery _query; public GetBitwardenSubscriptionQueryTests() { _query = new GetBitwardenSubscriptionQuery( _logger, _pricingClient, _stripeAdapter); } [Fact] public async Task Run_IncompleteStatus_ReturnsBitwardenSubscriptionWithSuspension() { var user = CreateUser(); var subscription = CreateSubscription(SubscriptionStatus.Incomplete); var premiumPlans = CreatePremiumPlans(); _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) .Returns(subscription); _pricingClient.ListPremiumPlans().Returns(premiumPlans); _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) .Returns(CreateInvoicePreview()); var result = await _query.Run(user); Assert.NotNull(result); Assert.Equal(SubscriptionStatus.Incomplete, result.Status); Assert.NotNull(result.Suspension); Assert.Equal(subscription.Created.AddHours(23), result.Suspension); Assert.Equal(1, result.GracePeriod); Assert.Null(result.NextCharge); Assert.Null(result.CancelAt); } [Fact] public async Task Run_IncompleteExpiredStatus_ReturnsBitwardenSubscriptionWithSuspension() { var user = CreateUser(); var subscription = CreateSubscription(SubscriptionStatus.IncompleteExpired); var premiumPlans = CreatePremiumPlans(); _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) .Returns(subscription); _pricingClient.ListPremiumPlans().Returns(premiumPlans); _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) .Returns(CreateInvoicePreview()); var result = await _query.Run(user); Assert.NotNull(result); Assert.Equal(SubscriptionStatus.IncompleteExpired, result.Status); Assert.NotNull(result.Suspension); Assert.Equal(subscription.Created.AddHours(23), result.Suspension); Assert.Equal(1, result.GracePeriod); } [Fact] public async Task Run_TrialingStatus_ReturnsBitwardenSubscriptionWithNextCharge() { var user = CreateUser(); var subscription = CreateSubscription(SubscriptionStatus.Trialing); var premiumPlans = CreatePremiumPlans(); _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) .Returns(subscription); _pricingClient.ListPremiumPlans().Returns(premiumPlans); _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) .Returns(CreateInvoicePreview()); var result = await _query.Run(user); Assert.NotNull(result); Assert.Equal(SubscriptionStatus.Trialing, result.Status); Assert.NotNull(result.NextCharge); Assert.Equal(subscription.Items.First().CurrentPeriodEnd, result.NextCharge); Assert.Null(result.Suspension); Assert.Null(result.GracePeriod); } [Fact] public async Task Run_ActiveStatus_ReturnsBitwardenSubscriptionWithNextCharge() { var user = CreateUser(); var subscription = CreateSubscription(SubscriptionStatus.Active); var premiumPlans = CreatePremiumPlans(); _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) .Returns(subscription); _pricingClient.ListPremiumPlans().Returns(premiumPlans); _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) .Returns(CreateInvoicePreview()); var result = await _query.Run(user); Assert.NotNull(result); Assert.Equal(SubscriptionStatus.Active, result.Status); Assert.NotNull(result.NextCharge); Assert.Equal(subscription.Items.First().CurrentPeriodEnd, result.NextCharge); Assert.Null(result.Suspension); Assert.Null(result.GracePeriod); } [Fact] public async Task Run_ActiveStatusWithCancelAt_ReturnsCancelAt() { var user = CreateUser(); var cancelAt = DateTime.UtcNow.AddMonths(1); var subscription = CreateSubscription(SubscriptionStatus.Active, cancelAt: cancelAt); var premiumPlans = CreatePremiumPlans(); _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) .Returns(subscription); _pricingClient.ListPremiumPlans().Returns(premiumPlans); _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) .Returns(CreateInvoicePreview()); var result = await _query.Run(user); Assert.NotNull(result); Assert.Equal(SubscriptionStatus.Active, result.Status); Assert.Equal(cancelAt, result.CancelAt); } [Fact] public async Task Run_PastDueStatus_WithOpenInvoices_ReturnsSuspension() { var user = CreateUser(); var subscription = CreateSubscription(SubscriptionStatus.PastDue, collectionMethod: "charge_automatically"); var premiumPlans = CreatePremiumPlans(); var openInvoice = CreateInvoice(); _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) .Returns(subscription); _pricingClient.ListPremiumPlans().Returns(premiumPlans); _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) .Returns(CreateInvoicePreview()); _stripeAdapter.SearchInvoiceAsync(Arg.Any()) .Returns([openInvoice]); var result = await _query.Run(user); Assert.NotNull(result); Assert.Equal(SubscriptionStatus.PastDue, result.Status); Assert.NotNull(result.Suspension); Assert.Equal(openInvoice.Created.AddDays(14), result.Suspension); Assert.Equal(14, result.GracePeriod); } [Fact] public async Task Run_PastDueStatus_WithoutOpenInvoices_ReturnsNoSuspension() { var user = CreateUser(); var subscription = CreateSubscription(SubscriptionStatus.PastDue); var premiumPlans = CreatePremiumPlans(); _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) .Returns(subscription); _pricingClient.ListPremiumPlans().Returns(premiumPlans); _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) .Returns(CreateInvoicePreview()); _stripeAdapter.SearchInvoiceAsync(Arg.Any()) .Returns([]); var result = await _query.Run(user); Assert.NotNull(result); Assert.Equal(SubscriptionStatus.PastDue, result.Status); Assert.Null(result.Suspension); Assert.Null(result.GracePeriod); } [Fact] public async Task Run_UnpaidStatus_WithOpenInvoices_ReturnsSuspension() { var user = CreateUser(); var subscription = CreateSubscription(SubscriptionStatus.Unpaid, collectionMethod: "charge_automatically"); var premiumPlans = CreatePremiumPlans(); var openInvoice = CreateInvoice(); _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) .Returns(subscription); _pricingClient.ListPremiumPlans().Returns(premiumPlans); _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) .Returns(CreateInvoicePreview()); _stripeAdapter.SearchInvoiceAsync(Arg.Any()) .Returns([openInvoice]); var result = await _query.Run(user); Assert.NotNull(result); Assert.Equal(SubscriptionStatus.Unpaid, result.Status); Assert.NotNull(result.Suspension); Assert.Equal(14, result.GracePeriod); } [Fact] public async Task Run_CanceledStatus_ReturnsCanceledDate() { var user = CreateUser(); var canceledAt = DateTime.UtcNow.AddDays(-5); var subscription = CreateSubscription(SubscriptionStatus.Canceled, canceledAt: canceledAt); var premiumPlans = CreatePremiumPlans(); _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) .Returns(subscription); _pricingClient.ListPremiumPlans().Returns(premiumPlans); _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) .Returns(CreateInvoicePreview()); var result = await _query.Run(user); Assert.NotNull(result); Assert.Equal(SubscriptionStatus.Canceled, result.Status); Assert.Equal(canceledAt, result.Canceled); Assert.Null(result.Suspension); Assert.Null(result.NextCharge); } [Fact] public async Task Run_UnmanagedStatus_ThrowsConflictException() { var user = CreateUser(); var subscription = CreateSubscription("unmanaged_status"); var premiumPlans = CreatePremiumPlans(); _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) .Returns(subscription); _pricingClient.ListPremiumPlans().Returns(premiumPlans); _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) .Returns(CreateInvoicePreview()); await Assert.ThrowsAsync(() => _query.Run(user)); } [Fact] public async Task Run_WithAdditionalStorage_IncludesStorageInCart() { var user = CreateUser(); var subscription = CreateSubscription(SubscriptionStatus.Active, includeStorage: true); var premiumPlans = CreatePremiumPlans(); _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) .Returns(subscription); _pricingClient.ListPremiumPlans().Returns(premiumPlans); _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) .Returns(CreateInvoicePreview()); var result = await _query.Run(user); Assert.NotNull(result); Assert.NotNull(result.Cart.PasswordManager.AdditionalStorage); Assert.Equal("additionalStorageGB", result.Cart.PasswordManager.AdditionalStorage.TranslationKey); Assert.Equal(2, result.Cart.PasswordManager.AdditionalStorage.Quantity); Assert.NotNull(result.Storage); } [Fact] public async Task Run_WithoutAdditionalStorage_ExcludesStorageFromCart() { var user = CreateUser(); var subscription = CreateSubscription(SubscriptionStatus.Active, includeStorage: false); var premiumPlans = CreatePremiumPlans(); _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) .Returns(subscription); _pricingClient.ListPremiumPlans().Returns(premiumPlans); _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) .Returns(CreateInvoicePreview()); var result = await _query.Run(user); Assert.NotNull(result); Assert.Null(result.Cart.PasswordManager.AdditionalStorage); Assert.NotNull(result.Storage); } [Fact] public async Task Run_WithCartLevelDiscount_IncludesDiscountInCart() { var user = CreateUser(); var subscription = CreateSubscription(SubscriptionStatus.Active); subscription.Customer.Discount = CreateDiscount(discountType: "cart"); var premiumPlans = CreatePremiumPlans(); _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) .Returns(subscription); _pricingClient.ListPremiumPlans().Returns(premiumPlans); _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) .Returns(CreateInvoicePreview()); var result = await _query.Run(user); Assert.NotNull(result); Assert.NotNull(result.Cart.Discount); Assert.Equal(BitwardenDiscountType.PercentOff, result.Cart.Discount.Type); Assert.Equal(20, result.Cart.Discount.Value); } [Fact] public async Task Run_WithProductLevelDiscount_IncludesDiscountInCartItem() { var user = CreateUser(); var subscription = CreateSubscription(SubscriptionStatus.Active); var productDiscount = CreateDiscount(discountType: "product", productId: "prod_premium_seat"); subscription.Discounts = [productDiscount]; var premiumPlans = CreatePremiumPlans(); _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) .Returns(subscription); _pricingClient.ListPremiumPlans().Returns(premiumPlans); _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) .Returns(CreateInvoicePreview()); var result = await _query.Run(user); Assert.NotNull(result); Assert.NotNull(result.Cart.PasswordManager.Seats.Discount); Assert.Equal(BitwardenDiscountType.PercentOff, result.Cart.PasswordManager.Seats.Discount.Type); } [Fact] public async Task Run_WithoutMaxStorageGb_ReturnsNullStorage() { var user = CreateUser(); user.MaxStorageGb = null; var subscription = CreateSubscription(SubscriptionStatus.Active); var premiumPlans = CreatePremiumPlans(); _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) .Returns(subscription); _pricingClient.ListPremiumPlans().Returns(premiumPlans); _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) .Returns(CreateInvoicePreview()); var result = await _query.Run(user); Assert.NotNull(result); Assert.Null(result.Storage); } [Fact] public async Task Run_CalculatesStorageCorrectly() { var user = CreateUser(); user.Storage = 5368709120; // 5 GB in bytes user.MaxStorageGb = 10; var subscription = CreateSubscription(SubscriptionStatus.Active, includeStorage: true); var premiumPlans = CreatePremiumPlans(); _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) .Returns(subscription); _pricingClient.ListPremiumPlans().Returns(premiumPlans); _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) .Returns(CreateInvoicePreview()); var result = await _query.Run(user); Assert.NotNull(result); Assert.NotNull(result.Storage); Assert.Equal(10, result.Storage.Available); Assert.Equal(5.0, result.Storage.Used); Assert.NotEmpty(result.Storage.ReadableUsed); } [Fact] public async Task Run_TaxEstimation_WithInvoiceUpcomingNoneError_ReturnsZeroTax() { var user = CreateUser(); var subscription = CreateSubscription(SubscriptionStatus.Active); var premiumPlans = CreatePremiumPlans(); _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) .Returns(subscription); _pricingClient.ListPremiumPlans().Returns(premiumPlans); _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) .ThrowsAsync(new StripeException { StripeError = new StripeError { Code = ErrorCodes.InvoiceUpcomingNone } }); var result = await _query.Run(user); Assert.NotNull(result); Assert.Equal(0, result.Cart.EstimatedTax); } [Fact] public async Task Run_MissingPasswordManagerSeatsItem_ThrowsConflictException() { var user = CreateUser(); var subscription = CreateSubscription(SubscriptionStatus.Active); subscription.Items = new StripeList { Data = [] }; var premiumPlans = CreatePremiumPlans(); _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) .Returns(subscription); _pricingClient.ListPremiumPlans().Returns(premiumPlans); await Assert.ThrowsAsync(() => _query.Run(user)); } [Fact] public async Task Run_IncludesEstimatedTax() { var user = CreateUser(); var subscription = CreateSubscription(SubscriptionStatus.Active); var premiumPlans = CreatePremiumPlans(); var invoice = CreateInvoicePreview(totalTax: 500); // $5.00 tax _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) .Returns(subscription); _pricingClient.ListPremiumPlans().Returns(premiumPlans); _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) .Returns(invoice); var result = await _query.Run(user); Assert.NotNull(result); Assert.Equal(5.0m, result.Cart.EstimatedTax); } [Fact] public async Task Run_SetsCadenceToAnnually() { var user = CreateUser(); var subscription = CreateSubscription(SubscriptionStatus.Active); var premiumPlans = CreatePremiumPlans(); _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) .Returns(subscription); _pricingClient.ListPremiumPlans().Returns(premiumPlans); _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) .Returns(CreateInvoicePreview()); var result = await _query.Run(user); Assert.NotNull(result); Assert.Equal(PlanCadenceType.Annually, result.Cart.Cadence); } #region Helper Methods private static User CreateUser() { return new User { Id = Guid.NewGuid(), GatewaySubscriptionId = "sub_test123", MaxStorageGb = 1, Storage = 1073741824 // 1 GB in bytes }; } private static Subscription CreateSubscription( string status, bool includeStorage = false, DateTime? cancelAt = null, DateTime? canceledAt = null, string collectionMethod = "charge_automatically") { var currentPeriodEnd = DateTime.UtcNow.AddMonths(1); var items = new List { new() { Id = "si_premium_seat", Price = new Price { Id = "price_premium_seat", UnitAmountDecimal = 1000, Product = new Product { Id = "prod_premium_seat" } }, Quantity = 1, CurrentPeriodStart = DateTime.UtcNow, CurrentPeriodEnd = currentPeriodEnd } }; if (includeStorage) { items.Add(new SubscriptionItem { Id = "si_storage", Price = new Price { Id = "price_storage", UnitAmountDecimal = 400, Product = new Product { Id = "prod_storage" } }, Quantity = 2, CurrentPeriodStart = DateTime.UtcNow, CurrentPeriodEnd = currentPeriodEnd }); } return new Subscription { Id = "sub_test123", Status = status, Created = DateTime.UtcNow.AddMonths(-1), Customer = new Customer { Id = "cus_test123", Discount = null }, Items = new StripeList { Data = items }, CancelAt = cancelAt, CanceledAt = canceledAt, CollectionMethod = collectionMethod, Discounts = [] }; } private static List CreatePremiumPlans() { return [ new() { Name = "Premium", Available = true, Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable { StripePriceId = "price_premium_seat", Price = 10.0m, Provided = 1 }, Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable { StripePriceId = "price_storage", Price = 4.0m, Provided = 1 } } ]; } private static Invoice CreateInvoice() { return new Invoice { Id = "in_test123", Created = DateTime.UtcNow.AddDays(-10), PeriodEnd = DateTime.UtcNow.AddDays(-5), Attempted = true, Status = "open" }; } private static Invoice CreateInvoicePreview(long totalTax = 0) { var taxes = totalTax > 0 ? new List { new() { Amount = totalTax } } : new List(); return new Invoice { Id = "in_preview", TotalTaxes = taxes }; } private static Discount CreateDiscount(string discountType = "cart", string? productId = null) { var coupon = new Coupon { Valid = true, PercentOff = 20, AppliesTo = discountType == "product" && productId != null ? new CouponAppliesTo { Products = [productId] } : new CouponAppliesTo { Products = [] } }; return new Discount { Coupon = coupon }; } #endregion }