diff --git a/src/Api/Billing/Controllers/PreviewInvoiceController.cs b/src/Api/Billing/Controllers/PreviewInvoiceController.cs index b2e2dcbad9..7e1e6b7da2 100644 --- a/src/Api/Billing/Controllers/PreviewInvoiceController.cs +++ b/src/Api/Billing/Controllers/PreviewInvoiceController.cs @@ -70,6 +70,13 @@ public class PreviewInvoiceController( planType, billingAddress); - return Handle(result.Map(pair => new { pair.Tax, pair.Total, pair.Credit })); + return Handle(result.Map(proration => new + { + NewPlanProratedTotal = proration.NewPlanProratedAmount, + proration.Credit, + proration.Tax, + proration.Total, + proration.NewPlanProratedMonths + })); } } diff --git a/src/Core/Billing/Premium/Commands/PreviewPremiumUpgradeProrationCommand.cs b/src/Core/Billing/Premium/Commands/PreviewPremiumUpgradeProrationCommand.cs index 4c864677df..af2a8bdacb 100644 --- a/src/Core/Billing/Premium/Commands/PreviewPremiumUpgradeProrationCommand.cs +++ b/src/Core/Billing/Premium/Commands/PreviewPremiumUpgradeProrationCommand.cs @@ -2,6 +2,7 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Premium.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Entities; @@ -22,8 +23,8 @@ public interface IPreviewPremiumUpgradeProrationCommand /// The user with an active Premium subscription. /// The target organization plan type. /// The billing address for tax calculation. - /// A tuple containing the tax amount, total cost, and proration credit from unused Premium time. - Task> Run( + /// The proration details for the upgrade including costs, credits, tax, and time remaining. + Task> Run( User user, PlanType targetPlanType, BillingAddress billingAddress); @@ -36,19 +37,16 @@ public class PreviewPremiumUpgradeProrationCommand( : BaseBillingCommand(logger), IPreviewPremiumUpgradeProrationCommand { - public Task> Run( + public Task> Run( User user, PlanType targetPlanType, - BillingAddress billingAddress) => HandleAsync<(decimal, decimal, decimal)>(async () => + BillingAddress billingAddress) => HandleAsync(async () => { if (user is not { Premium: true, GatewaySubscriptionId: not null and not "" }) { return new BadRequest("User does not have an active Premium subscription."); } - // Hardcode seats to 1 for upgrade flow - const int seats = 1; - var currentSubscription = await stripeAdapter.GetSubscriptionAsync( user.GatewaySubscriptionId, new SubscriptionGetOptions { Expand = ["customer"] }); @@ -63,11 +61,7 @@ public class PreviewPremiumUpgradeProrationCommand( 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 subscriptionItems = new List(); var storageItem = currentSubscription.Items.Data.FirstOrDefault(i => i.Price.Id == usersPremiumPlan.Storage.StripePriceId); @@ -81,10 +75,12 @@ public class PreviewPremiumUpgradeProrationCommand( }); } + // Hardcode seats to 1 for upgrade flow if (targetPlan.HasNonSeatBasedPasswordManagerPlan()) { subscriptionItems.Add(new InvoiceSubscriptionDetailsItemOptions { + Id = passwordManagerItem.Id, Price = targetPlan.PasswordManager.StripePlanId, Quantity = 1 }); @@ -93,8 +89,9 @@ public class PreviewPremiumUpgradeProrationCommand( { subscriptionItems.Add(new InvoiceSubscriptionDetailsItemOptions { + Id = passwordManagerItem.Id, Price = targetPlan.PasswordManager.StripeSeatPlanId, - Quantity = seats + Quantity = 1 }); } @@ -114,21 +111,26 @@ public class PreviewPremiumUpgradeProrationCommand( SubscriptionDetails = new InvoiceSubscriptionDetailsOptions { Items = subscriptionItems, - ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations + ProrationBehavior = StripeConstants.ProrationBehavior.AlwaysInvoice } }; var invoicePreview = await stripeAdapter.CreateInvoicePreviewAsync(options); - var amounts = GetAmounts(invoicePreview); + var proration = GetProration(invoicePreview, passwordManagerItem); - return amounts; + return proration; }); - 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 PremiumUpgradeProration GetProration(Invoice invoicePreview, SubscriptionItem passwordManagerItem) => new() + { + NewPlanProratedAmount = GetNewPlanProratedAmountFromInvoice(invoicePreview), + Credit = GetProrationCreditFromInvoice(invoicePreview), + Tax = Convert.ToDecimal(invoicePreview.TotalTaxes.Sum(invoiceTotalTax => invoiceTotalTax.Amount)) / 100, + Total = Convert.ToDecimal(invoicePreview.Total) / 100, + // Use invoice periodEnd here instead of UtcNow so that testing with Stripe time clocks works correctly. And if there is no test clock, + // (like in production), the previewInvoice's periodEnd is the same as UtcNow anyway because of the proration behavior (always_invoice) + NewPlanProratedMonths = CalculateNewPlanProratedMonths(invoicePreview.PeriodEnd, passwordManagerItem.CurrentPeriodEnd) + }; private static decimal GetProrationCreditFromInvoice(Invoice invoicePreview) { @@ -139,4 +141,26 @@ public class PreviewPremiumUpgradeProrationCommand( return Convert.ToDecimal(prorationCredit) / 100; } + + private static decimal GetNewPlanProratedAmountFromInvoice(Invoice invoicePreview) + { + // The target plan's prorated upgrade amount should be the only positive-valued line item + var proratedTotal = invoicePreview.Lines?.Data? + .Where(line => line.Amount > 0) + .Sum(line => line.Amount) ?? 0; + + return Convert.ToDecimal(proratedTotal) / 100; + } + + private static int CalculateNewPlanProratedMonths(DateTime invoicePeriodEnd, DateTime currentPeriodEnd) + { + var daysInProratedPeriod = (currentPeriodEnd - invoicePeriodEnd).TotalDays; + + // Round to nearest month (30-day periods) + // 1-14 days = 1 month, 15-44 days = 1 month, 45-74 days = 2 months, etc. + // Minimum is always 1 month (never returns 0) + // Use MidpointRounding.AwayFromZero to round 0.5 up to 1 + var months = (int)Math.Round(daysInProratedPeriod / 30, MidpointRounding.AwayFromZero); + return Math.Max(1, months); + } } diff --git a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs index d3e2eb899f..5a879d3d8e 100644 --- a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs +++ b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs @@ -88,13 +88,6 @@ public class UpgradePremiumToOrganizationCommand( // Build the list of subscription item updates var subscriptionItemOptions = new List(); - // Delete the user's specific password manager item - subscriptionItemOptions.Add(new SubscriptionItemOptions - { - Id = passwordManagerItem.Id, - Deleted = true - }); - // Delete the storage item if it exists for this user's plan var storageItem = currentSubscription.Items.Data.FirstOrDefault(i => i.Price.Id == usersPremiumPlan.Storage.StripePriceId); @@ -116,6 +109,7 @@ public class UpgradePremiumToOrganizationCommand( { subscriptionItemOptions.Add(new SubscriptionItemOptions { + Id = passwordManagerItem.Id, Price = targetPlan.PasswordManager.StripePlanId, Quantity = 1 }); @@ -124,6 +118,7 @@ public class UpgradePremiumToOrganizationCommand( { subscriptionItemOptions.Add(new SubscriptionItemOptions { + Id = passwordManagerItem.Id, Price = targetPlan.PasswordManager.StripeSeatPlanId, Quantity = seats }); @@ -136,7 +131,8 @@ public class UpgradePremiumToOrganizationCommand( var subscriptionUpdateOptions = new SubscriptionUpdateOptions { Items = subscriptionItemOptions, - ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations, + ProrationBehavior = StripeConstants.ProrationBehavior.AlwaysInvoice, + BillingCycleAnchor = SubscriptionBillingCycleAnchor.Unchanged, AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }, Metadata = new Dictionary { @@ -149,11 +145,6 @@ public class UpgradePremiumToOrganizationCommand( } }; - if (targetPlan.TrialPeriodDays.HasValue) - { - subscriptionUpdateOptions.TrialEnd = DateTime.UtcNow.AddDays((double)targetPlan.TrialPeriodDays); - } - // Create the Organization entity var organization = new Organization { @@ -161,7 +152,7 @@ public class UpgradePremiumToOrganizationCommand( Name = organizationName, BillingEmail = user.Email, PlanType = targetPlan.Type, - Seats = (short)seats, + Seats = seats, MaxCollections = targetPlan.PasswordManager.MaxCollections, MaxStorageGb = targetPlan.PasswordManager.BaseStorageGb, UsePolicies = targetPlan.HasPolicies, diff --git a/src/Core/Billing/Premium/Models/PremiumUpgradeProration.cs b/src/Core/Billing/Premium/Models/PremiumUpgradeProration.cs new file mode 100644 index 0000000000..d8acaa3170 --- /dev/null +++ b/src/Core/Billing/Premium/Models/PremiumUpgradeProration.cs @@ -0,0 +1,36 @@ +namespace Bit.Core.Billing.Premium.Models; + +/// +/// Represents the proration details for upgrading a Premium user subscription to an Organization plan. +/// +public class PremiumUpgradeProration +{ + /// + /// The prorated cost for the new organization plan, calculated from now until the end of the current billing period. + /// This represents what the user will pay for the upgraded plan for the remainder of the period. + /// + public decimal NewPlanProratedAmount { get; set; } + + /// + /// The credit amount for the unused portion of the current Premium subscription. + /// This credit is applied against the cost of the new organization plan. + /// + public decimal Credit { get; set; } + + /// + /// The tax amount calculated for the upgrade transaction. + /// + public decimal Tax { get; set; } + + /// + /// The total amount due for the upgrade after applying the credit and adding tax. + /// + public decimal Total { get; set; } + + /// + /// The number of months the user will be charged for the new organization plan in the prorated billing period. + /// Calculated by rounding the days remaining in the current billing cycle to the nearest month. + /// Minimum value is 1 month (never returns 0). + /// + public int NewPlanProratedMonths { get; set; } +} diff --git a/test/Core.Test/Billing/Premium/Commands/PreviewPremiumUpgradeProrationCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/PreviewPremiumUpgradeProrationCommandTests.cs index 475c458361..c2af07f633 100644 --- a/test/Core.Test/Billing/Premium/Commands/PreviewPremiumUpgradeProrationCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/PreviewPremiumUpgradeProrationCommandTests.cs @@ -91,6 +91,8 @@ public class PreviewPremiumUpgradeProrationCommandTests var premiumPlans = new List { premiumPlan }; // Setup current Stripe subscription + var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var currentPeriodEnd = now.AddMonths(6); var currentSubscription = new Subscription { Id = "sub_123", @@ -106,7 +108,8 @@ public class PreviewPremiumUpgradeProrationCommandTests new() { Id = "si_premium", - Price = new Price { Id = "premium-annually" } + Price = new Price { Id = "premium-annually" }, + CurrentPeriodEnd = currentPeriodEnd } } } @@ -122,7 +125,15 @@ public class PreviewPremiumUpgradeProrationCommandTests TotalTaxes = new List { new() { Amount = 500 } // $5.00 - } + }, + Lines = new StripeList + { + Data = new List + { + new() { Amount = 5000 } // $50.00 for new plan + } + }, + PeriodEnd = now }; // Configure mocks @@ -140,10 +151,12 @@ public class PreviewPremiumUpgradeProrationCommandTests // 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); + var proration = result.AsT0; + Assert.Equal(50.00m, proration.NewPlanProratedAmount); + Assert.Equal(0m, proration.Credit); + Assert.Equal(5.00m, proration.Tax); + Assert.Equal(50.00m, proration.Total); + Assert.Equal(6, proration.NewPlanProratedMonths); // 6 months remaining } [Theory, BitAutoData] @@ -174,6 +187,9 @@ public class PreviewPremiumUpgradeProrationCommandTests }; var premiumPlans = new List { premiumPlan }; + // Use fixed time to avoid DateTime.UtcNow differences + var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var currentPeriodEnd = now.AddDays(45); // 1.5 months ~ 2 months rounded var currentSubscription = new Subscription { Id = "sub_123", @@ -182,7 +198,7 @@ public class PreviewPremiumUpgradeProrationCommandTests { Data = new List { - new() { Id = "si_premium", Price = new Price { Id = "premium-annually" } } + new() { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = currentPeriodEnd } } } }; @@ -201,7 +217,8 @@ public class PreviewPremiumUpgradeProrationCommandTests new() { Amount = -1000 }, // -$10.00 credit from unused Premium new() { Amount = 5000 } // $50.00 for new plan } - } + }, + PeriodEnd = now }; _pricingClient.ListPremiumPlans().Returns(premiumPlans); @@ -216,10 +233,12 @@ public class PreviewPremiumUpgradeProrationCommandTests // 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 + var proration = result.AsT0; + Assert.Equal(50.00m, proration.NewPlanProratedAmount); + Assert.Equal(10.00m, proration.Credit); // Proration credit + Assert.Equal(4.00m, proration.Tax); + Assert.Equal(40.00m, proration.Total); + Assert.Equal(2, proration.NewPlanProratedMonths); // 45 days rounds to 2 months } [Theory, BitAutoData] @@ -258,7 +277,7 @@ public class PreviewPremiumUpgradeProrationCommandTests { Data = new List { - new() { Id = "si_premium", Price = new Price { Id = "premium-annually" } } + new() { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) } } } }; @@ -268,7 +287,9 @@ public class PreviewPremiumUpgradeProrationCommandTests var invoice = new Invoice { Total = 5000, - TotalTaxes = new List { new() { Amount = 500 } } + TotalTaxes = new List { new() { Amount = 500 } }, + Lines = new StripeList { Data = new List { new() { Amount = 5000 } } }, + PeriodEnd = DateTime.UtcNow }; _pricingClient.ListPremiumPlans().Returns(premiumPlans); @@ -281,10 +302,11 @@ public class PreviewPremiumUpgradeProrationCommandTests // Act await _command.Run(user, PlanType.TeamsAnnually, billingAddress); - // Assert - Verify that the subscription item quantity is always 1 + // Assert - Verify that the subscription item quantity is always 1 and has Id await _stripeAdapter.Received(1).CreateInvoicePreviewAsync( Arg.Is(options => options.SubscriptionDetails.Items.Any(item => + item.Id == "si_premium" && item.Price == targetPlan.PasswordManager.StripeSeatPlanId && item.Quantity == 1))); } @@ -325,8 +347,8 @@ public class PreviewPremiumUpgradeProrationCommandTests { 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" } } + new() { Id = "si_password_manager", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) }, + new() { Id = "si_storage", Price = new Price { Id = "storage-gb-annually" }, CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) } } } }; @@ -336,7 +358,9 @@ public class PreviewPremiumUpgradeProrationCommandTests var invoice = new Invoice { Total = 5000, - TotalTaxes = new List { new() { Amount = 500 } } + TotalTaxes = new List { new() { Amount = 500 } }, + Lines = new StripeList { Data = new List { new() { Amount = 5000 } } }, + PeriodEnd = DateTime.UtcNow }; _pricingClient.ListPremiumPlans().Returns(premiumPlans); @@ -349,11 +373,15 @@ public class PreviewPremiumUpgradeProrationCommandTests // Act await _command.Run(user, PlanType.TeamsAnnually, billingAddress); - // Assert - Verify both password manager and storage items are marked as deleted + // Assert - Verify password manager item is modified and storage item is deleted await _stripeAdapter.Received(1).CreateInvoicePreviewAsync( Arg.Is(options => + // Password manager item should be modified to new plan price, not deleted options.SubscriptionDetails.Items.Any(item => - item.Id == "si_password_manager" && item.Deleted == true) && + item.Id == "si_password_manager" && + item.Price == targetPlan.PasswordManager.StripeSeatPlanId && + item.Deleted != true) && + // Storage item should be deleted options.SubscriptionDetails.Items.Any(item => item.Id == "si_storage" && item.Deleted == true))); } @@ -394,7 +422,7 @@ public class PreviewPremiumUpgradeProrationCommandTests { Data = new List { - new() { Id = "si_premium", Price = new Price { Id = "premium-annually" } } + new() { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) } } } }; @@ -404,7 +432,9 @@ public class PreviewPremiumUpgradeProrationCommandTests var invoice = new Invoice { Total = 5000, - TotalTaxes = new List { new() { Amount = 500 } } + TotalTaxes = new List { new() { Amount = 500 } }, + Lines = new StripeList { Data = new List { new() { Amount = 5000 } } }, + PeriodEnd = DateTime.UtcNow }; _pricingClient.ListPremiumPlans().Returns(premiumPlans); @@ -417,10 +447,11 @@ public class PreviewPremiumUpgradeProrationCommandTests // Act await _command.Run(user, PlanType.FamiliesAnnually, billingAddress); - // Assert - Verify non-seat-based plan uses StripePlanId with quantity 1 + // Assert - Verify non-seat-based plan uses StripePlanId with quantity 1 and modifies existing item await _stripeAdapter.Received(1).CreateInvoicePreviewAsync( Arg.Is(options => options.SubscriptionDetails.Items.Any(item => + item.Id == "si_premium" && item.Price == targetPlan.PasswordManager.StripePlanId && item.Quantity == 1))); } @@ -463,7 +494,7 @@ public class PreviewPremiumUpgradeProrationCommandTests { Data = new List { - new() { Id = "si_premium", Price = new Price { Id = "premium-annually" } } + new() { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) } } } }; @@ -473,7 +504,9 @@ public class PreviewPremiumUpgradeProrationCommandTests var invoice = new Invoice { Total = 5000, - TotalTaxes = new List { new() { Amount = 500 } } + TotalTaxes = new List { new() { Amount = 500 } }, + Lines = new StripeList { Data = new List { new() { Amount = 5000 } } }, + PeriodEnd = DateTime.UtcNow }; _pricingClient.ListPremiumPlans().Returns(premiumPlans); @@ -494,7 +527,7 @@ public class PreviewPremiumUpgradeProrationCommandTests options.Subscription == "sub_123" && options.CustomerDetails.Address.Country == "US" && options.CustomerDetails.Address.PostalCode == "12345" && - options.SubscriptionDetails.ProrationBehavior == "create_prorations")); + options.SubscriptionDetails.ProrationBehavior == "always_invoice")); } [Theory, BitAutoData] @@ -533,7 +566,7 @@ public class PreviewPremiumUpgradeProrationCommandTests { Data = new List { - new() { Id = "si_premium", Price = new Price { Id = "premium-annually" } } + new() { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) } } } }; @@ -544,7 +577,9 @@ public class PreviewPremiumUpgradeProrationCommandTests var invoice = new Invoice { Total = 5000, - TotalTaxes = new List { new() { Amount = 500 } } + TotalTaxes = new List { new() { Amount = 500 } }, + Lines = new StripeList { Data = new List { new() { Amount = 5000 } } }, + PeriodEnd = DateTime.UtcNow }; _pricingClient.ListPremiumPlans().Returns(premiumPlans); @@ -557,11 +592,186 @@ public class PreviewPremiumUpgradeProrationCommandTests // Act await _command.Run(user, PlanType.TeamsAnnually, billingAddress); - // Assert - Verify seat-based plan uses StripeSeatPlanId with quantity 1 + // Assert - Verify seat-based plan uses StripeSeatPlanId with quantity 1 and modifies existing item await _stripeAdapter.Received(1).CreateInvoicePreviewAsync( Arg.Is(options => options.SubscriptionDetails.Items.Any(item => + item.Id == "si_premium" && item.Price == targetPlan.PasswordManager.StripeSeatPlanId && item.Quantity == 1))); } + + [Theory] + [InlineData(0, 1)] // Less than 15 days, minimum 1 month + [InlineData(1, 1)] // 1 day = 1 month minimum + [InlineData(14, 1)] // 14 days = 1 month minimum + [InlineData(15, 1)] // 15 days rounds to 1 month + [InlineData(30, 1)] // 30 days = 1 month + [InlineData(44, 1)] // 44 days rounds to 1 month + [InlineData(45, 2)] // 45 days rounds to 2 months + [InlineData(60, 2)] // 60 days = 2 months + [InlineData(90, 3)] // 90 days = 3 months + [InlineData(180, 6)] // 180 days = 6 months + [InlineData(365, 12)] // 365 days rounds to 12 months + public async Task Run_ValidUpgrade_CalculatesNewPlanProratedMonthsCorrectly(int daysRemaining, int expectedMonths) + { + // Arrange + var user = new User + { + Premium = true, + GatewaySubscriptionId = "sub_123", + GatewayCustomerId = "cus_123" + }; + var billingAddress = new Core.Billing.Payment.Models.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 }; + + // Use fixed time to avoid DateTime.UtcNow differences + var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var currentPeriodEnd = now.AddDays(daysRemaining); + 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" }, CurrentPeriodEnd = currentPeriodEnd } + } + } + }; + + var targetPlan = new TeamsPlan(isAnnual: true); + + var invoice = new Invoice + { + Total = 5000, + TotalTaxes = new List { new() { Amount = 500 } }, + Lines = new StripeList + { + Data = new List { new() { Amount = 5000 } } + }, + PeriodEnd = now + }; + + _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, PlanType.TeamsAnnually, billingAddress); + + // Assert + Assert.True(result.IsT0); + var proration = result.AsT0; + Assert.Equal(expectedMonths, proration.NewPlanProratedMonths); + } + + [Theory, BitAutoData] + public async Task Run_ValidUpgrade_ReturnsNewPlanProratedAmountCorrectly(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 now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var currentPeriodEnd = now.AddMonths(3); + 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" }, CurrentPeriodEnd = currentPeriodEnd } + } + } + }; + + var targetPlan = new TeamsPlan(isAnnual: true); + + // Invoice showing new plan cost, credit, and net + var invoice = new Invoice + { + Total = 4500, // $45.00 net after $5 credit + TotalTaxes = new List { new() { Amount = 450 } }, // $4.50 + Lines = new StripeList + { + Data = new List + { + new() { Amount = -500 }, // -$5.00 credit + new() { Amount = 5000 } // $50.00 for new plan + } + }, + PeriodEnd = now + }; + + _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, PlanType.TeamsAnnually, billingAddress); + + // Assert + Assert.True(result.IsT0); + var proration = result.AsT0; + + Assert.Equal(50.00m, proration.NewPlanProratedAmount); + Assert.Equal(5.00m, proration.Credit); + Assert.Equal(4.50m, proration.Tax); + Assert.Equal(45.00m, proration.Total); + } } + diff --git a/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs index 702e449746..932c486e74 100644 --- a/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs @@ -256,9 +256,8 @@ public class UpgradePremiumToOrganizationCommandTests await _stripeAdapter.Received(1).UpdateSubscriptionAsync( "sub_123", Arg.Is(opts => - opts.Items.Count == 2 && // 1 deleted + 1 seat (no storage) - opts.Items.Any(i => i.Deleted == true) && - opts.Items.Any(i => i.Price == "teams-seat-annually" && i.Quantity == 1))); + opts.Items.Count == 1 && // Only 1 item: modify existing password manager item (no storage to delete) + opts.Items.Any(i => i.Id == "si_premium" && i.Price == "teams-seat-annually" && i.Quantity == 1 && i.Deleted != true))); await _organizationRepository.Received(1).CreateAsync(Arg.Is(o => o.Name == "My Organization" && @@ -331,9 +330,8 @@ public class UpgradePremiumToOrganizationCommandTests await _stripeAdapter.Received(1).UpdateSubscriptionAsync( "sub_123", Arg.Is(opts => - opts.Items.Count == 2 && // 1 deleted + 1 plan - opts.Items.Any(i => i.Deleted == true) && - opts.Items.Any(i => i.Price == "families-plan-annually" && i.Quantity == 1))); + opts.Items.Count == 1 && // Only 1 item: modify existing password manager item (no storage to delete) + opts.Items.Any(i => i.Id == "si_premium" && i.Price == "families-plan-annually" && i.Quantity == 1 && i.Deleted != true))); await _organizationRepository.Received(1).CreateAsync(Arg.Is(o => o.Name == "My Families Org")); @@ -461,14 +459,13 @@ public class UpgradePremiumToOrganizationCommandTests // Assert Assert.True(result.IsT0); - // Verify that BOTH legacy items (password manager + storage) are deleted by ID + // Verify that legacy password manager item is modified and legacy storage is deleted await _stripeAdapter.Received(1).UpdateSubscriptionAsync( "sub_123", Arg.Is(opts => - opts.Items.Count == 3 && // 2 deleted (legacy PM + legacy storage) + 1 new seat - opts.Items.Count(i => i.Deleted == true && i.Id == "si_premium_legacy") == 1 && // Legacy PM deleted - opts.Items.Count(i => i.Deleted == true && i.Id == "si_storage_legacy") == 1 && // Legacy storage deleted - opts.Items.Any(i => i.Price == "teams-seat-annually" && i.Quantity == 1))); + opts.Items.Count == 2 && // 1 modified (legacy PM to new price) + 1 deleted (legacy storage) + opts.Items.Count(i => i.Id == "si_premium_legacy" && i.Price == "teams-seat-annually" && i.Quantity == 1 && i.Deleted != true) == 1 && // Legacy PM modified + opts.Items.Count(i => i.Deleted == true && i.Id == "si_storage_legacy") == 1)); // Legacy storage deleted } [Theory, BitAutoData] @@ -528,15 +525,14 @@ public class UpgradePremiumToOrganizationCommandTests // Assert Assert.True(result.IsT0); - // Verify that ONLY the premium password manager item is deleted (not other products) - // Note: We delete the specific premium item by ID, so other products are untouched + // Verify that ONLY the premium password manager item is modified (not other products) + // Note: We modify the specific premium item by ID, so other products are untouched await _stripeAdapter.Received(1).UpdateSubscriptionAsync( "sub_123", Arg.Is(opts => - opts.Items.Count == 2 && // 1 deleted (premium password manager) + 1 new seat - opts.Items.Count(i => i.Deleted == true && i.Id == "si_premium") == 1 && // Premium item deleted by ID - opts.Items.Count(i => i.Id == "si_other_product") == 0 && // Other product NOT in update (untouched) - opts.Items.Any(i => i.Price == "teams-seat-annually" && i.Quantity == 1))); + opts.Items.Count == 1 && // Only modify premium password manager item + opts.Items.Count(i => i.Id == "si_premium" && i.Price == "teams-seat-annually" && i.Quantity == 1 && i.Deleted != true) == 1 && // Premium item modified + opts.Items.Count(i => i.Id == "si_other_product") == 0)); // Other product NOT in update (untouched) } [Theory, BitAutoData] @@ -603,8 +599,8 @@ public class UpgradePremiumToOrganizationCommandTests Arg.Is(opts => opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousAdditionalStorage) && opts.Metadata[StripeConstants.MetadataKeys.PreviousAdditionalStorage] == "5" && - opts.Items.Count == 3 && // 2 deleted (premium + storage) + 1 new seat - opts.Items.Count(i => i.Deleted == true) == 2)); + opts.Items.Count == 2 && // 1 modified (premium to new price) + 1 deleted (storage) + opts.Items.Count(i => i.Deleted == true) == 1)); } [Theory, BitAutoData] @@ -647,77 +643,6 @@ public class UpgradePremiumToOrganizationCommandTests Assert.Equal("Premium subscription password manager item not found.", badRequest.Response); } - [Theory, BitAutoData] - public async Task Run_PlanWithTrialPeriod_SetsTrialEnd(User user) - { - // Arrange - user.Premium = true; - user.GatewaySubscriptionId = "sub_123"; - user.GatewayCustomerId = "cus_123"; - - var currentPeriodEnd = DateTime.UtcNow.AddMonths(1); - var mockSubscription = new Subscription - { - Id = "sub_123", - Items = new StripeList - { - Data = new List - { - new SubscriptionItem - { - Id = "si_premium", - Price = new Price { Id = "premium-annually" }, - CurrentPeriodEnd = currentPeriodEnd - } - } - }, - Metadata = new Dictionary() - }; - - var mockPremiumPlans = CreateTestPremiumPlansList(); - - // Create a plan with a trial period - var mockPlan = CreateTestPlan( - PlanType.TeamsAnnually, - stripeSeatPlanId: "teams-seat-annually", - trialPeriodDays: 7 - ); - - // Capture the subscription update options to verify TrialEnd is set - SubscriptionUpdateOptions capturedOptions = null; - - _stripeAdapter.GetSubscriptionAsync("sub_123") - .Returns(mockSubscription); - _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); - _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); - _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Do(opts => capturedOptions = opts)) - .Returns(Task.FromResult(mockSubscription)); - _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); - _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); - _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); - _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); - _userService.SaveUserAsync(user).Returns(Task.CompletedTask); - - // Act - var testStartTime = DateTime.UtcNow; - var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress()); - var testEndTime = DateTime.UtcNow; - - // Assert - Assert.True(result.IsT0); - - await _stripeAdapter.Received(1).UpdateSubscriptionAsync("sub_123", Arg.Any()); - - Assert.NotNull(capturedOptions); - Assert.NotNull(capturedOptions.TrialEnd); - - // TrialEnd is AnyOf - verify it's a DateTime - var trialEndDateTime = capturedOptions.TrialEnd.Value as DateTime?; - Assert.NotNull(trialEndDateTime); - Assert.True(trialEndDateTime.Value >= testStartTime.AddDays(7)); - Assert.True(trialEndDateTime.Value <= testEndTime.AddDays(7)); - } - [Theory, BitAutoData] public async Task Run_UpdatesCustomerBillingAddress(User user) { @@ -823,4 +748,272 @@ public class UpgradePremiumToOrganizationCommandTests opts.AutomaticTax != null && opts.AutomaticTax.Enabled == true)); } + + [Theory, BitAutoData] + public async Task Run_UsesAlwaysInvoiceProrationBehavior(User user) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var mockSubscription = new Subscription + { + Id = "sub_123", + Items = new StripeList + { + Data = new List + { + new SubscriptionItem + { + Id = "si_premium", + Price = new Price { Id = "premium-annually" } + } + } + }, + Metadata = new Dictionary() + }; + + var mockPremiumPlans = CreateTestPremiumPlansList(); + var mockPlan = CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually"); + + _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription); + _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); + _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()).Returns(mockSubscription); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(new Customer())); + _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); + _userService.SaveUserAsync(user).Returns(Task.CompletedTask); + + // Act + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress()); + + // Assert + Assert.True(result.IsT0); + + await _stripeAdapter.Received(1).UpdateSubscriptionAsync( + "sub_123", + Arg.Is(opts => + opts.ProrationBehavior == "always_invoice")); + } + + [Theory, BitAutoData] + public async Task Run_ModifiesExistingSubscriptionItem_NotDeleteAndRecreate(User user) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var mockSubscription = new Subscription + { + Id = "sub_123", + Items = new StripeList + { + Data = new List + { + new SubscriptionItem + { + Id = "si_premium", + Price = new Price { Id = "premium-annually" } + } + } + }, + Metadata = new Dictionary() + }; + + var mockPremiumPlans = CreateTestPremiumPlansList(); + var mockPlan = CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually"); + + _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription); + _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); + _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()).Returns(mockSubscription); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(new Customer())); + _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); + _userService.SaveUserAsync(user).Returns(Task.CompletedTask); + + // Act + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress()); + + // Assert + Assert.True(result.IsT0); + + // Verify that the subscription item was modified, not deleted + await _stripeAdapter.Received(1).UpdateSubscriptionAsync( + "sub_123", + Arg.Is(opts => + // Should have an item with the original ID being modified + opts.Items.Any(item => + item.Id == "si_premium" && + item.Price == "teams-seat-annually" && + item.Quantity == 1 && + item.Deleted != true))); + } + + [Theory, BitAutoData] + public async Task Run_CreatesOrganizationWithCorrectSettings(User user) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var mockSubscription = new Subscription + { + Id = "sub_123", + Items = new StripeList + { + Data = new List + { + new SubscriptionItem + { + Id = "si_premium", + Price = new Price { Id = "premium-annually" } + } + } + }, + Metadata = new Dictionary() + }; + + var mockPremiumPlans = CreateTestPremiumPlansList(); + var mockPlan = CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually"); + + _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription); + _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); + _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()).Returns(mockSubscription); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(new Customer())); + _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); + _userService.SaveUserAsync(user).Returns(Task.CompletedTask); + + // Act + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress()); + + // Assert + Assert.True(result.IsT0); + + await _organizationRepository.Received(1).CreateAsync( + Arg.Is(org => + org.Name == "My Organization" && + org.BillingEmail == user.Email && + org.PlanType == PlanType.TeamsAnnually && + org.Seats == 1 && + org.Gateway == GatewayType.Stripe && + org.GatewayCustomerId == "cus_123" && + org.GatewaySubscriptionId == "sub_123" && + org.Enabled == true)); + } + + [Theory, BitAutoData] + public async Task Run_CreatesOrganizationApiKeyWithCorrectType(User user) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var mockSubscription = new Subscription + { + Id = "sub_123", + Items = new StripeList + { + Data = new List + { + new SubscriptionItem + { + Id = "si_premium", + Price = new Price { Id = "premium-annually" } + } + } + }, + Metadata = new Dictionary() + }; + + var mockPremiumPlans = CreateTestPremiumPlansList(); + var mockPlan = CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually"); + + _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription); + _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); + _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()).Returns(mockSubscription); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(new Customer())); + _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); + _userService.SaveUserAsync(user).Returns(Task.CompletedTask); + + // Act + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress()); + + // Assert + Assert.True(result.IsT0); + + await _organizationApiKeyRepository.Received(1).CreateAsync( + Arg.Is(apiKey => + apiKey.Type == OrganizationApiKeyType.Default && + !string.IsNullOrEmpty(apiKey.ApiKey))); + } + + [Theory, BitAutoData] + public async Task Run_CreatesOrganizationUserAsOwnerWithAllPermissions(User user) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var mockSubscription = new Subscription + { + Id = "sub_123", + Items = new StripeList + { + Data = new List + { + new SubscriptionItem + { + Id = "si_premium", + Price = new Price { Id = "premium-annually" } + } + } + }, + Metadata = new Dictionary() + }; + + var mockPremiumPlans = CreateTestPremiumPlansList(); + var mockPlan = CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually"); + + _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription); + _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); + _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()).Returns(mockSubscription); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(new Customer())); + _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); + _userService.SaveUserAsync(user).Returns(Task.CompletedTask); + + // Act + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress()); + + // Assert + Assert.True(result.IsT0); + + await _organizationUserRepository.Received(1).CreateAsync( + Arg.Is(orgUser => + orgUser.UserId == user.Id && + orgUser.Type == OrganizationUserType.Owner && + orgUser.Status == OrganizationUserStatusType.Confirmed)); + } }