diff --git a/src/Core/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQuery.cs b/src/Core/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQuery.cs index cd7fa91fff..51c51bd7b2 100644 --- a/src/Core/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQuery.cs +++ b/src/Core/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQuery.cs @@ -14,6 +14,7 @@ namespace Bit.Core.Billing.Subscriptions.Queries; using static StripeConstants; using static Utilities; +using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan; public interface IGetBitwardenSubscriptionQuery { @@ -107,11 +108,28 @@ public class GetBitwardenSubscriptionQuery( var (cartLevelDiscount, productLevelDiscounts) = GetStripeDiscounts(subscription); + var availablePlan = plans.First(plan => plan.Available); + var onCurrentPricing = passwordManagerSeatsItem.Price.Id == availablePlan.Seat.StripePriceId; + + decimal seatCost; + decimal estimatedTax; + + if (onCurrentPricing) + { + seatCost = GetCost(passwordManagerSeatsItem); + estimatedTax = await EstimatePremiumTaxAsync(subscription); + } + else + { + seatCost = availablePlan.Seat.Price; + estimatedTax = await EstimatePremiumTaxAsync(subscription, plans, availablePlan); + } + var passwordManagerSeats = new CartItem { TranslationKey = "premiumMembership", Quantity = passwordManagerSeatsItem.Quantity, - Cost = GetCost(passwordManagerSeatsItem), + Cost = seatCost, Discount = productLevelDiscounts.FirstOrDefault(discount => discount.AppliesTo(passwordManagerSeatsItem)) }; @@ -125,8 +143,6 @@ public class GetBitwardenSubscriptionQuery( } : null; - var estimatedTax = await EstimateTaxAsync(subscription); - return new Cart { PasswordManager = new PasswordManagerCartItems @@ -142,15 +158,45 @@ public class GetBitwardenSubscriptionQuery( #region Utilities - private async Task EstimateTaxAsync(Subscription subscription) + private async Task EstimatePremiumTaxAsync( + Subscription subscription, + List? plans = null, + PremiumPlan? availablePlan = null) { try { - var invoice = await stripeAdapter.CreateInvoicePreviewAsync(new InvoiceCreatePreviewOptions + var options = new InvoiceCreatePreviewOptions { - Customer = subscription.Customer.Id, - Subscription = subscription.Id - }); + Customer = subscription.Customer.Id + }; + + if (plans != null && availablePlan != null) + { + options.AutomaticTax = new InvoiceAutomaticTaxOptions + { + Enabled = subscription.AutomaticTax?.Enabled ?? false + }; + + options.SubscriptionDetails = new InvoiceSubscriptionDetailsOptions + { + Items = subscription.Items.Select(item => + { + var isSeatItem = plans.Any(plan => plan.Seat.StripePriceId == item.Price.Id); + + return new InvoiceSubscriptionDetailsItemOptions + { + Price = isSeatItem ? availablePlan.Seat.StripePriceId : item.Price.Id, + Quantity = item.Quantity + }; + }).ToList() + }; + } + else + { + options.Subscription = subscription.Id; + } + + var invoice = await stripeAdapter.CreateInvoicePreviewAsync(options); return GetCost(invoice.TotalTaxes); } diff --git a/test/Core.Test/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQueryTests.cs b/test/Core.Test/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQueryTests.cs index a12a0e4cb0..e0a11741b3 100644 --- a/test/Core.Test/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQueryTests.cs +++ b/test/Core.Test/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQueryTests.cs @@ -461,6 +461,77 @@ public class GetBitwardenSubscriptionQueryTests Assert.Equal(PlanCadenceType.Annually, result.Cart.Cadence); } + [Fact] + public async Task Run_UserOnLegacyPricing_ReturnsCostFromPricingService() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.Active, legacyPricing: true); + var premiumPlans = CreatePremiumPlans(); + var availablePlan = premiumPlans.First(p => p.Available); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + + var previewInvoice = CreateInvoicePreview(totalTax: 150); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(previewInvoice); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Equal(availablePlan.Seat.Price, result.Cart.PasswordManager.Seats.Cost); + Assert.Equal(1.50m, result.Cart.EstimatedTax); + } + + [Fact] + public async Task Run_UserOnLegacyPricing_CallsPreviewInvoiceWithRebuiltSubscription() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.Active, legacyPricing: true); + var premiumPlans = CreatePremiumPlans(); + var availablePlan = premiumPlans.First(p => p.Available); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + + var previewInvoice = CreateInvoicePreview(); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(previewInvoice); + + await _query.Run(user); + + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync( + Arg.Is(opts => + opts.Subscription == null && + opts.AutomaticTax != null && + opts.AutomaticTax.Enabled == true && + opts.SubscriptionDetails != null && + opts.SubscriptionDetails.Items.Any(i => + i.Price == availablePlan.Seat.StripePriceId && + i.Quantity == 1))); + } + + [Fact] + public async Task Run_UserOnCurrentPricing_ReturnsCostFromSubscriptionItem() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.Active, legacyPricing: 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.Equal(19.80m, result.Cart.PasswordManager.Seats.Cost); + } + #region Helper Methods private static User CreateUser() @@ -477,11 +548,14 @@ public class GetBitwardenSubscriptionQueryTests private static Subscription CreateSubscription( string status, bool includeStorage = false, + bool legacyPricing = false, DateTime? cancelAt = null, DateTime? canceledAt = null, string collectionMethod = "charge_automatically") { var currentPeriodEnd = DateTime.UtcNow.AddMonths(1); + var seatPriceId = legacyPricing ? "price_legacy_premium_seat" : "price_premium_seat"; + var seatUnitAmount = legacyPricing ? 1000 : 1980; var items = new List { new() @@ -489,8 +563,8 @@ public class GetBitwardenSubscriptionQueryTests Id = "si_premium_seat", Price = new Price { - Id = "price_premium_seat", - UnitAmountDecimal = 1000, + Id = seatPriceId, + UnitAmountDecimal = seatUnitAmount, Product = new Product { Id = "prod_premium_seat" } }, Quantity = 1, @@ -521,6 +595,7 @@ public class GetBitwardenSubscriptionQueryTests Id = "sub_test123", Status = status, Created = DateTime.UtcNow.AddMonths(-1), + AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, Customer = new Customer { Id = "cus_test123", @@ -548,6 +623,24 @@ public class GetBitwardenSubscriptionQueryTests Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable { StripePriceId = "price_premium_seat", + Price = 19.80m, + Provided = 1 + }, + Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "price_storage", + Price = 4.0m, + Provided = 1 + } + }, + new() + { + Name = "Premium", + Available = false, + LegacyYear = 2024, + Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "price_legacy_premium_seat", Price = 10.0m, Provided = 1 },