From 75c49b9b5822b40d5235db15ffce383a4b8344ba Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Thu, 15 Jan 2026 18:28:18 -0600 Subject: [PATCH] [PM-29599] create proration preview endpoint --- ...troller.cs => PreviewInvoiceController.cs} | 49 +- ...izationSubscriptionPlanChangeTaxRequest.cs | 2 +- ...anizationSubscriptionPurchaseTaxRequest.cs | 2 +- ...rganizationSubscriptionUpdateTaxRequest.cs | 2 +- ...ewPremiumSubscriptionPurchaseTaxRequest.cs | 2 +- .../PreviewPremiumUpgradeProrationRequest.cs | 18 + .../Extensions/ServiceCollectionExtensions.cs | 1 + .../PreviewPremiumUpgradeProrationCommand.cs | 157 +++++ ...viewPremiumUpgradeProrationCommandTests.cs | 661 ++++++++++++++++++ 9 files changed, 866 insertions(+), 28 deletions(-) rename src/Api/Billing/Controllers/{TaxController.cs => PreviewInvoiceController.cs} (64%) rename src/Api/Billing/Models/Requests/{Tax => PreviewInvoice}/PreviewOrganizationSubscriptionPlanChangeTaxRequest.cs (91%) rename src/Api/Billing/Models/Requests/{Tax => PreviewInvoice}/PreviewOrganizationSubscriptionPurchaseTaxRequest.cs (91%) rename src/Api/Billing/Models/Requests/{Tax => PreviewInvoice}/PreviewOrganizationSubscriptionUpdateTaxRequest.cs (84%) rename src/Api/Billing/Models/Requests/{Tax => PreviewInvoice}/PreviewPremiumSubscriptionPurchaseTaxRequest.cs (90%) create mode 100644 src/Api/Billing/Models/Requests/PreviewInvoice/PreviewPremiumUpgradeProrationRequest.cs create mode 100644 src/Core/Billing/Premium/Commands/PreviewPremiumUpgradeProrationCommand.cs create mode 100644 test/Core.Test/Billing/Premium/Commands/PreviewPremiumUpgradeProrationCommandTests.cs diff --git a/src/Api/Billing/Controllers/TaxController.cs b/src/Api/Billing/Controllers/PreviewInvoiceController.cs similarity index 64% rename from src/Api/Billing/Controllers/TaxController.cs rename to src/Api/Billing/Controllers/PreviewInvoiceController.cs index 4ead414589..8cde24b4fe 100644 --- a/src/Api/Billing/Controllers/TaxController.cs +++ b/src/Api/Billing/Controllers/PreviewInvoiceController.cs @@ -1,8 +1,9 @@ using Bit.Api.Billing.Attributes; -using Bit.Api.Billing.Models.Requests.Tax; +using Bit.Api.Billing.Models.Requests.PreviewInvoice; using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Organizations.Commands; using Bit.Core.Billing.Premium.Commands; +using Bit.Core.Entities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; @@ -10,10 +11,11 @@ using Microsoft.AspNetCore.Mvc.ModelBinding; namespace Bit.Api.Billing.Controllers; [Authorize("Application")] -[Route("billing/tax")] -public class TaxController( +[Route("billing/preview-invoice")] +public class PreviewInvoiceController( IPreviewOrganizationTaxCommand previewOrganizationTaxCommand, - IPreviewPremiumTaxCommand previewPremiumTaxCommand) : BaseBillingController + IPreviewPremiumTaxCommand previewPremiumTaxCommand, + IPreviewPremiumUpgradeProrationCommand previewPremiumUpgradeProrationCommand) : BaseBillingController { [HttpPost("organizations/subscriptions/purchase")] public async Task PreviewOrganizationSubscriptionPurchaseTaxAsync( @@ -21,11 +23,7 @@ public class TaxController( { var (purchase, billingAddress) = request.ToDomain(); var result = await previewOrganizationTaxCommand.Run(purchase, billingAddress); - return Handle(result.Map(pair => new - { - pair.Tax, - pair.Total - })); + return Handle(result.Map(pair => new { pair.Tax, pair.Total })); } [HttpPost("organizations/{organizationId:guid}/subscription/plan-change")] @@ -36,11 +34,7 @@ public class TaxController( { var (planChange, billingAddress) = request.ToDomain(); var result = await previewOrganizationTaxCommand.Run(organization, planChange, billingAddress); - return Handle(result.Map(pair => new - { - pair.Tax, - pair.Total - })); + return Handle(result.Map(pair => new { pair.Tax, pair.Total })); } [HttpPut("organizations/{organizationId:guid}/subscription/update")] @@ -51,11 +45,7 @@ public class TaxController( { var update = request.ToDomain(); var result = await previewOrganizationTaxCommand.Run(organization, update); - return Handle(result.Map(pair => new - { - pair.Tax, - pair.Total - })); + return Handle(result.Map(pair => new { pair.Tax, pair.Total })); } [HttpPost("premium/subscriptions/purchase")] @@ -64,10 +54,21 @@ public class TaxController( { var (purchase, billingAddress) = request.ToDomain(); var result = await previewPremiumTaxCommand.Run(purchase, billingAddress); - return Handle(result.Map(pair => new - { - pair.Tax, - pair.Total - })); + return Handle(result.Map(pair => new { pair.Tax, pair.Total })); + } + + [HttpPost("premium/subscriptions/upgrade")] + public async Task PreviewPremiumUpgradeProrationAsync( + [BindNever] User user, + [FromBody] PreviewPremiumUpgradeProrationRequest request) + { + var (tierType, billingAddress) = request.ToDomain(); + + var result = await previewPremiumUpgradeProrationCommand.Run( + user, + tierType, + billingAddress); + + return Handle(result.Map(pair => new { pair.Tax, pair.Total, pair.Credit })); } } diff --git a/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPlanChangeTaxRequest.cs b/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewOrganizationSubscriptionPlanChangeTaxRequest.cs similarity index 91% rename from src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPlanChangeTaxRequest.cs rename to src/Api/Billing/Models/Requests/PreviewInvoice/PreviewOrganizationSubscriptionPlanChangeTaxRequest.cs index 9233a53c85..ccb8f948af 100644 --- a/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPlanChangeTaxRequest.cs +++ b/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewOrganizationSubscriptionPlanChangeTaxRequest.cs @@ -4,7 +4,7 @@ using Bit.Api.Billing.Models.Requests.Payment; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Payment.Models; -namespace Bit.Api.Billing.Models.Requests.Tax; +namespace Bit.Api.Billing.Models.Requests.PreviewInvoice; public record PreviewOrganizationSubscriptionPlanChangeTaxRequest { diff --git a/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPurchaseTaxRequest.cs b/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewOrganizationSubscriptionPurchaseTaxRequest.cs similarity index 91% rename from src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPurchaseTaxRequest.cs rename to src/Api/Billing/Models/Requests/PreviewInvoice/PreviewOrganizationSubscriptionPurchaseTaxRequest.cs index dcc5911f3d..40bec9dec3 100644 --- a/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPurchaseTaxRequest.cs +++ b/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewOrganizationSubscriptionPurchaseTaxRequest.cs @@ -4,7 +4,7 @@ using Bit.Api.Billing.Models.Requests.Payment; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Payment.Models; -namespace Bit.Api.Billing.Models.Requests.Tax; +namespace Bit.Api.Billing.Models.Requests.PreviewInvoice; public record PreviewOrganizationSubscriptionPurchaseTaxRequest { diff --git a/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionUpdateTaxRequest.cs b/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewOrganizationSubscriptionUpdateTaxRequest.cs similarity index 84% rename from src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionUpdateTaxRequest.cs rename to src/Api/Billing/Models/Requests/PreviewInvoice/PreviewOrganizationSubscriptionUpdateTaxRequest.cs index ae96214ae3..4568fea972 100644 --- a/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionUpdateTaxRequest.cs +++ b/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewOrganizationSubscriptionUpdateTaxRequest.cs @@ -1,7 +1,7 @@ using Bit.Api.Billing.Models.Requests.Organizations; using Bit.Core.Billing.Organizations.Models; -namespace Bit.Api.Billing.Models.Requests.Tax; +namespace Bit.Api.Billing.Models.Requests.PreviewInvoice; public class PreviewOrganizationSubscriptionUpdateTaxRequest { diff --git a/src/Api/Billing/Models/Requests/Tax/PreviewPremiumSubscriptionPurchaseTaxRequest.cs b/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewPremiumSubscriptionPurchaseTaxRequest.cs similarity index 90% rename from src/Api/Billing/Models/Requests/Tax/PreviewPremiumSubscriptionPurchaseTaxRequest.cs rename to src/Api/Billing/Models/Requests/PreviewInvoice/PreviewPremiumSubscriptionPurchaseTaxRequest.cs index 76b8a5a444..d1707cf6de 100644 --- a/src/Api/Billing/Models/Requests/Tax/PreviewPremiumSubscriptionPurchaseTaxRequest.cs +++ b/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewPremiumSubscriptionPurchaseTaxRequest.cs @@ -2,7 +2,7 @@ using Bit.Api.Billing.Models.Requests.Payment; using Bit.Core.Billing.Payment.Models; -namespace Bit.Api.Billing.Models.Requests.Tax; +namespace Bit.Api.Billing.Models.Requests.PreviewInvoice; public record PreviewPremiumSubscriptionPurchaseTaxRequest { diff --git a/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewPremiumUpgradeProrationRequest.cs b/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewPremiumUpgradeProrationRequest.cs new file mode 100644 index 0000000000..e3c109a155 --- /dev/null +++ b/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewPremiumUpgradeProrationRequest.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.Billing.Models.Requests.Payment; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Payment.Models; + +namespace Bit.Api.Billing.Models.Requests.PreviewInvoice; + +public record PreviewPremiumUpgradeProrationRequest +{ + [Required] + public required ProductTierType TargetProductTierType { get; set; } + + [Required] + public required MinimalBillingAddressRequest BillingAddress { get; set; } + + public (ProductTierType, BillingAddress) ToDomain() => + (TargetProductTierType, BillingAddress.ToDomain()); +} diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index c61c4e6279..ddf3479aa3 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -59,6 +59,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddTransient(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); } diff --git a/src/Core/Billing/Premium/Commands/PreviewPremiumUpgradeProrationCommand.cs b/src/Core/Billing/Premium/Commands/PreviewPremiumUpgradeProrationCommand.cs new file mode 100644 index 0000000000..edb0255328 --- /dev/null +++ b/src/Core/Billing/Premium/Commands/PreviewPremiumUpgradeProrationCommand.cs @@ -0,0 +1,157 @@ +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Microsoft.Extensions.Logging; +using Stripe; + +namespace Bit.Core.Billing.Premium.Commands; + +/// +/// Previews the proration details for upgrading a Premium user subscription to an Organization +/// plan by using the Stripe API to create an invoice preview, prorated, for the upgrade. +/// +public interface IPreviewPremiumUpgradeProrationCommand +{ + /// + /// Calculates the tax, total cost, and proration credit for upgrading a Premium subscription to an Organization plan. + /// + /// The user with an active Premium subscription. + /// The target organization tier (Families, Teams, or Enterprise). + /// The billing address for tax calculation. + /// A tuple containing the tax amount, total cost, and proration credit from unused Premium time. + Task> Run( + User user, + ProductTierType targetProductTierType, + BillingAddress billingAddress); +} + +public class PreviewPremiumUpgradeProrationCommand( + ILogger logger, + IPricingClient pricingClient, + IStripeAdapter stripeAdapter) + : BaseBillingCommand(logger), + IPreviewPremiumUpgradeProrationCommand +{ + public Task> Run( + User user, + ProductTierType targetProductTierType, + BillingAddress billingAddress) => HandleAsync<(decimal, decimal, decimal)>(async () => + { + if (user is not { Premium: true, GatewaySubscriptionId: not null and not "" }) + { + return new BadRequest("User does not have an active Premium subscription."); + } + + if (targetProductTierType is not (ProductTierType.Families or ProductTierType.Teams or ProductTierType.Enterprise)) + { + return new BadRequest($"Cannot upgrade Premium subscription to {targetProductTierType} plan."); + } + + // Convert ProductTierType to PlanType (for premium upgrade, the only choice is annual plans so we can assume that cadence) + var targetPlanType = targetProductTierType switch + { + ProductTierType.Families => PlanType.FamiliesAnnually, + ProductTierType.Teams => PlanType.TeamsAnnually, + ProductTierType.Enterprise => PlanType.EnterpriseAnnually, + _ => throw new InvalidOperationException($"Unexpected ProductTierType: {targetProductTierType}") + }; + + // Hardcode seats to 1 for upgrade flow + const int seats = 1; + + var currentSubscription = await stripeAdapter.GetSubscriptionAsync( + user.GatewaySubscriptionId, + new SubscriptionGetOptions { Expand = ["customer"] }); + var premiumPlans = await pricingClient.ListPremiumPlans(); + var passwordManagerItem = currentSubscription.Items.Data.FirstOrDefault(i => + premiumPlans.Any(p => p.Seat.StripePriceId == i.Price.Id)); + + if (passwordManagerItem == null) + { + return new BadRequest("Premium subscription password manager item not found."); + } + + var usersPremiumPlan = premiumPlans.First(p => p.Seat.StripePriceId == passwordManagerItem.Price.Id); + var targetPlan = await pricingClient.GetPlanOrThrow(targetPlanType); + var subscriptionItems = new List + { + // Delete the user's specific password manager item + new() { Id = passwordManagerItem.Id, Deleted = true } + }; + var storageItem = currentSubscription.Items.Data.FirstOrDefault(i => + i.Price.Id == usersPremiumPlan.Storage.StripePriceId); + + // Delete the storage item if it exists for this user's plan + if (storageItem != null) + { + subscriptionItems.Add(new InvoiceSubscriptionDetailsItemOptions + { + Id = storageItem.Id, + Deleted = true + }); + } + + if (targetPlan.HasNonSeatBasedPasswordManagerPlan()) + { + subscriptionItems.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = targetPlan.PasswordManager.StripePlanId, + Quantity = 1 + }); + } + else + { + subscriptionItems.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = targetPlan.PasswordManager.StripeSeatPlanId, + Quantity = seats + }); + } + + var options = new InvoiceCreatePreviewOptions + { + AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true }, + Currency = "usd", + Customer = user.GatewayCustomerId, + Subscription = user.GatewaySubscriptionId, + CustomerDetails = new InvoiceCustomerDetailsOptions + { + Address = new AddressOptions + { + Country = billingAddress.Country, + PostalCode = billingAddress.PostalCode + } + }, + SubscriptionDetails = new InvoiceSubscriptionDetailsOptions + { + Items = subscriptionItems, + ProrationBehavior = StripeConstants.ProrationBehavior.AlwaysInvoice + } + }; + + var invoicePreview = await stripeAdapter.CreateInvoicePreviewAsync(options); + var amounts = GetAmounts(invoicePreview); + + return amounts; + }); + + private static (decimal, decimal, decimal) GetAmounts(Invoice invoicePreview) => ( + Convert.ToDecimal(invoicePreview.TotalTaxes.Sum(invoiceTotalTax => invoiceTotalTax.Amount)) / 100, + Convert.ToDecimal(invoicePreview.Total) / 100, + GetProrationCreditFromInvoice(invoicePreview)); + + + private static decimal GetProrationCreditFromInvoice(Invoice invoicePreview) + { + // Extract proration credit from negative line items (credits are negative in Stripe) + var prorationCredit = invoicePreview.Lines?.Data? + .Where(line => line.Amount < 0) + .Sum(line => Math.Abs(line.Amount)) ?? 0; // Return the credit as positive number + + return Convert.ToDecimal(prorationCredit) / 100; + } +} diff --git a/test/Core.Test/Billing/Premium/Commands/PreviewPremiumUpgradeProrationCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/PreviewPremiumUpgradeProrationCommandTests.cs new file mode 100644 index 0000000000..b1f60de67e --- /dev/null +++ b/test/Core.Test/Billing/Premium/Commands/PreviewPremiumUpgradeProrationCommandTests.cs @@ -0,0 +1,661 @@ +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Premium.Commands; +using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Test.Billing.Mocks.Plans; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Stripe; +using Xunit; +using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan; + +namespace Bit.Core.Test.Billing.Premium.Commands; + +public class PreviewPremiumUpgradeProrationCommandTests +{ + private readonly ILogger _logger = Substitute.For>(); + private readonly IPricingClient _pricingClient = Substitute.For(); + private readonly IStripeAdapter _stripeAdapter = Substitute.For(); + private readonly PreviewPremiumUpgradeProrationCommand _command; + + public PreviewPremiumUpgradeProrationCommandTests() + { + _command = new PreviewPremiumUpgradeProrationCommand( + _logger, + _pricingClient, + _stripeAdapter); + } + + [Theory, BitAutoData] + public async Task Run_UserWithoutPremium_ReturnsBadRequest(User user, BillingAddress billingAddress) + { + // Arrange + user.Premium = false; + + // Act + var result = await _command.Run(user, ProductTierType.Teams, billingAddress); + + // Assert + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal("User does not have an active Premium subscription.", badRequest.Response); + } + + [Theory, BitAutoData] + public async Task Run_UserWithoutGatewaySubscriptionId_ReturnsBadRequest(User user, BillingAddress billingAddress) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = null; + + // Act + var result = await _command.Run(user, ProductTierType.Teams, billingAddress); + + // Assert + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal("User does not have an active Premium subscription.", badRequest.Response); + } + + [Theory, BitAutoData] + public async Task Run_ValidUpgrade_ReturnsProrationAmounts(User user, BillingAddress billingAddress) + { + // Arrange - Setup valid Premium user + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + // Setup Premium plans + var premiumPlan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "premium-annually", + Price = 10m, + Provided = 1 + }, + Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "storage-gb-annually", + Price = 4m, + Provided = 1 + } + }; + + var premiumPlans = new List { premiumPlan }; + + // Setup current Stripe subscription + var currentSubscription = new Subscription + { + Id = "sub_123", + Customer = new Customer + { + Id = "cus_123", + Discount = null + }, + Items = new StripeList + { + Data = new List + { + new() + { + Id = "si_premium", + Price = new Price { Id = "premium-annually" } + } + } + } + }; + + // Setup target organization plan + var targetPlan = new TeamsPlan(isAnnual: true); + + // Setup invoice preview response + var invoice = new Invoice + { + Total = 5000, // $50.00 + TotalTaxes = new List + { + new() { Amount = 500 } // $5.00 + } + }; + + // Configure mocks + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(targetPlan); + _stripeAdapter.GetSubscriptionAsync( + "sub_123", + Arg.Any()) + .Returns(currentSubscription); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(invoice); + + // Act + var result = await _command.Run(user, ProductTierType.Teams, billingAddress); + + // Assert + Assert.True(result.IsT0); + var (tax, total, credit) = result.AsT0; + Assert.Equal(5.00m, tax); + Assert.Equal(50.00m, total); + Assert.Equal(0m, credit); + } + + [Theory, BitAutoData] + public async Task Run_ValidUpgrade_ExtractsProrationCredit(User user, BillingAddress billingAddress) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var premiumPlan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "premium-annually", + Price = 10m, + Provided = 1 + }, + Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "storage-gb-annually", + Price = 4m, + Provided = 1 + } + }; + var premiumPlans = new List { premiumPlan }; + + var currentSubscription = new Subscription + { + Id = "sub_123", + Customer = new Customer { Id = "cus_123", Discount = null }, + Items = new StripeList + { + Data = new List + { + new() { Id = "si_premium", Price = new Price { Id = "premium-annually" } } + } + } + }; + + var targetPlan = new TeamsPlan(isAnnual: true); + + // Invoice with negative line item (proration credit) + var invoice = new Invoice + { + Total = 4000, // $40.00 + TotalTaxes = new List { new() { Amount = 400 } }, // $4.00 + Lines = new StripeList + { + Data = new List + { + new() { Amount = -1000 }, // -$10.00 credit from unused Premium + new() { Amount = 5000 } // $50.00 for new plan + } + } + }; + + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(targetPlan); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()) + .Returns(currentSubscription); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(invoice); + + // Act + var result = await _command.Run(user, ProductTierType.Teams, billingAddress); + + // Assert + Assert.True(result.IsT0); + var (tax, total, credit) = result.AsT0; + Assert.Equal(4.00m, tax); + Assert.Equal(40.00m, total); + Assert.Equal(10.00m, credit); // Proration credit + } + + [Theory, BitAutoData] + public async Task Run_ValidUpgrade_AlwaysUsesOneSeat(User user, BillingAddress billingAddress) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var premiumPlan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "premium-annually", + Price = 10m, + Provided = 1 + }, + Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "storage-gb-annually", + Price = 4m, + Provided = 1 + } + }; + var premiumPlans = new List { premiumPlan }; + + var currentSubscription = new Subscription + { + Id = "sub_123", + Customer = new Customer { Id = "cus_123", Discount = null }, + Items = new StripeList + { + Data = new List + { + new() { Id = "si_premium", Price = new Price { Id = "premium-annually" } } + } + } + }; + + var targetPlan = new TeamsPlan(isAnnual: true); + + var invoice = new Invoice + { + Total = 5000, + TotalTaxes = new List { new() { Amount = 500 } } + }; + + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(targetPlan); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()) + .Returns(currentSubscription); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(invoice); + + // Act + await _command.Run(user, ProductTierType.Teams, billingAddress); + + // Assert - Verify that the subscription item quantity is always 1 + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync( + Arg.Is(options => + options.SubscriptionDetails.Items.Any(item => + item.Price == targetPlan.PasswordManager.StripeSeatPlanId && + item.Quantity == 1))); + } + + [Theory] + [InlineData(ProductTierType.Families, PlanType.FamiliesAnnually)] + [InlineData(ProductTierType.Teams, PlanType.TeamsAnnually)] + [InlineData(ProductTierType.Enterprise, PlanType.EnterpriseAnnually)] + public async Task Run_ProductTierTypeConversion_MapsToCorrectPlanType( + ProductTierType productTierType, + PlanType expectedPlanType) + { + // Arrange + var user = new User + { + Premium = true, + GatewaySubscriptionId = "sub_123", + GatewayCustomerId = "cus_123" + }; + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var premiumPlan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "premium-annually", + Price = 10m, + Provided = 1 + }, + Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "storage-gb-annually", + Price = 4m, + Provided = 1 + } + }; + var premiumPlans = new List { premiumPlan }; + + var currentSubscription = new Subscription + { + Id = "sub_123", + Customer = new Customer { Id = "cus_123", Discount = null }, + Items = new StripeList + { + Data = new List + { + new() { Id = "si_premium", Price = new Price { Id = "premium-annually" } } + } + } + }; + + var targetPlan = new TeamsPlan(isAnnual: true); + + var invoice = new Invoice + { + Total = 5000, + TotalTaxes = new List { new() { Amount = 500 } } + }; + + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _pricingClient.GetPlanOrThrow(expectedPlanType).Returns(targetPlan); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()) + .Returns(currentSubscription); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(invoice); + + // Act + await _command.Run(user, productTierType, billingAddress); + + // Assert - Verify that the correct PlanType was used + await _pricingClient.Received(1).GetPlanOrThrow(expectedPlanType); + } + + [Theory, BitAutoData] + public async Task Run_ValidUpgrade_DeletesPremiumSubscriptionItems(User user, BillingAddress billingAddress) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var premiumPlan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "premium-annually", + Price = 10m, + Provided = 1 + }, + Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "storage-gb-annually", + Price = 4m, + Provided = 1 + } + }; + var premiumPlans = new List { premiumPlan }; + + var currentSubscription = new Subscription + { + Id = "sub_123", + Customer = new Customer { Id = "cus_123", Discount = null }, + Items = new StripeList + { + Data = new List + { + new() { Id = "si_password_manager", Price = new Price { Id = "premium-annually" } }, + new() { Id = "si_storage", Price = new Price { Id = "storage-gb-annually" } } + } + } + }; + + var targetPlan = new TeamsPlan(isAnnual: true); + + var invoice = new Invoice + { + Total = 5000, + TotalTaxes = new List { new() { Amount = 500 } } + }; + + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(targetPlan); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()) + .Returns(currentSubscription); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(invoice); + + // Act + await _command.Run(user, ProductTierType.Teams, billingAddress); + + // Assert - Verify both password manager and storage items are marked as deleted + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync( + Arg.Is(options => + options.SubscriptionDetails.Items.Any(item => + item.Id == "si_password_manager" && item.Deleted == true) && + options.SubscriptionDetails.Items.Any(item => + item.Id == "si_storage" && item.Deleted == true))); + } + + [Theory, BitAutoData] + public async Task Run_NonSeatBasedPlan_UsesStripePlanId(User user, BillingAddress billingAddress) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var premiumPlan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "premium-annually", + Price = 10m, + Provided = 1 + }, + Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "storage-gb-annually", + Price = 4m, + Provided = 1 + } + }; + var premiumPlans = new List { premiumPlan }; + + var currentSubscription = new Subscription + { + Id = "sub_123", + Customer = new Customer { Id = "cus_123", Discount = null }, + Items = new StripeList + { + Data = new List + { + new() { Id = "si_premium", Price = new Price { Id = "premium-annually" } } + } + } + }; + + var targetPlan = new FamiliesPlan(); // families is non seat based + + var invoice = new Invoice + { + Total = 5000, + TotalTaxes = new List { new() { Amount = 500 } } + }; + + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(targetPlan); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()) + .Returns(currentSubscription); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(invoice); + + // Act + await _command.Run(user, ProductTierType.Families, billingAddress); + + // Assert - Verify non-seat-based plan uses StripePlanId with quantity 1 + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync( + Arg.Is(options => + options.SubscriptionDetails.Items.Any(item => + item.Price == targetPlan.PasswordManager.StripePlanId && + item.Quantity == 1))); + } + + [Theory, BitAutoData] + public async Task Run_ValidUpgrade_CreatesCorrectInvoicePreviewOptions(User user, BillingAddress billingAddress) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + billingAddress.Country = "US"; + billingAddress.PostalCode = "12345"; + + var premiumPlan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "premium-annually", + Price = 10m, + Provided = 1 + }, + Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "storage-gb-annually", + Price = 4m, + Provided = 1 + } + }; + var premiumPlans = new List { premiumPlan }; + + var currentSubscription = new Subscription + { + Id = "sub_123", + Customer = new Customer { Id = "cus_123", Discount = null }, + Items = new StripeList + { + Data = new List + { + new() { Id = "si_premium", Price = new Price { Id = "premium-annually" } } + } + } + }; + + var targetPlan = new TeamsPlan(isAnnual: true); + + var invoice = new Invoice + { + Total = 5000, + TotalTaxes = new List { new() { Amount = 500 } } + }; + + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(targetPlan); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()) + .Returns(currentSubscription); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(invoice); + + // Act + await _command.Run(user, ProductTierType.Teams, billingAddress); + + // Assert - Verify all invoice preview options are correct + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync( + Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.Customer == "cus_123" && + options.Subscription == "sub_123" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.SubscriptionDetails.ProrationBehavior == "always_invoice")); + } + + [Theory, BitAutoData] + public async Task Run_TeamsStarterTierType_ReturnsBadRequest(User user, BillingAddress billingAddress) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + // Act + var result = await _command.Run(user, ProductTierType.TeamsStarter, billingAddress); + + // Assert + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal("Cannot upgrade Premium subscription to TeamsStarter plan.", badRequest.Response); + } + + [Theory, BitAutoData] + public async Task Run_SeatBasedPlan_UsesStripeSeatPlanId(User user, BillingAddress billingAddress) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var premiumPlan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "premium-annually", + Price = 10m, + Provided = 1 + }, + Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "storage-gb-annually", + Price = 4m, + Provided = 1 + } + }; + var premiumPlans = new List { premiumPlan }; + + var currentSubscription = new Subscription + { + Id = "sub_123", + Customer = new Customer { Id = "cus_123", Discount = null }, + Items = new StripeList + { + Data = new List + { + new() { Id = "si_premium", Price = new Price { Id = "premium-annually" } } + } + } + }; + + // Use Teams which is seat-based + var targetPlan = new TeamsPlan(isAnnual: true); + + var invoice = new Invoice + { + Total = 5000, + TotalTaxes = new List { new() { Amount = 500 } } + }; + + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(targetPlan); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()) + .Returns(currentSubscription); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(invoice); + + // Act + await _command.Run(user, ProductTierType.Teams, billingAddress); + + // Assert - Verify seat-based plan uses StripeSeatPlanId with quantity 1 + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync( + Arg.Is(options => + options.SubscriptionDetails.Items.Any(item => + item.Price == targetPlan.PasswordManager.StripeSeatPlanId && + item.Quantity == 1))); + } +}